diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..60b8c6f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/modio.net"] + path = lib/modio.net + url = https://github.com/nickelc/modio.net.git diff --git a/installer/DesktopMagic-Installer.exe b/installer/DesktopMagic-Installer.exe index 2e40156..df24fc6 100644 Binary files a/installer/DesktopMagic-Installer.exe and b/installer/DesktopMagic-Installer.exe differ diff --git a/lib/modio.net b/lib/modio.net new file mode 160000 index 0000000..cc684b1 --- /dev/null +++ b/lib/modio.net @@ -0,0 +1 @@ +Subproject commit cc684b198febed1e01de44fd6c365954167558ba diff --git a/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs b/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs new file mode 100644 index 0000000..6b158f7 --- /dev/null +++ b/src/DesktopMagic/BuiltInPlugins/BenchmarkPlugin.cs @@ -0,0 +1,131 @@ +using DesktopMagic.Api; +using DesktopMagic.Api.Settings; + +using System; +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace DesktopMagic.BuiltInPlugins; + +#if DEBUG + +internal class BenchmarkPlugin : Plugin +{ + private readonly Random rng = new(42); + private readonly object rngLock = new(); + + private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew(); + private double fps; + private long lastFrameMs; + + [Setting("shape-count", "Shapes")] + private readonly IntegerUpDown shapeCount = new(10, 10000, 1000); + + [Setting("shape-type", "Shape Type")] + private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed"); + + [Setting("fps-counter", "Fps")] + private readonly Label fpsCounter = new(""); + + public override int UpdateInterval => 1; + + public override Bitmap? Main() + { + long now = frameTimer.ElapsedMilliseconds; + if (lastFrameMs > 0) + { + double elapsed = now - lastFrameMs; + fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1); + } + lastFrameMs = now; + + // Render at the full device-pixel window size so both benchmarks cover the + // same on-screen area at the same resolution for a fair comparison. + int w = Application.WindowPixelSize.Width; + int h = Application.WindowPixelSize.Height; + + Bitmap bmp = new Bitmap(w, h); + using Graphics g = Graphics.FromImage(bmp); + g.Clear(Color.Transparent); + g.SmoothingMode = SmoothingMode.HighSpeed; + g.CompositingQuality = CompositingQuality.HighSpeed; + g.InterpolationMode = InterpolationMode.Low; + + Color color = Application.Theme.PrimaryColor; + int count = shapeCount.Value; + string type = shapeType.Value; + + for (int i = 0; i < count; i++) + { + int x, y, rw, rh; + lock (rngLock) + { + x = rng.Next(0, w); + y = rng.Next(0, h); + rw = rng.Next(5, 60); + rh = rng.Next(5, 60); + } + + using Brush brush = new SolidBrush(Color.FromArgb(rng.Next(100, 255), color)); + + switch (type) + { + case "Rectangles": + g.FillRectangle(brush, x, y, rw, rh); + break; + case "Ellipses": + g.FillEllipse(brush, x, y, rw, rh); + break; + case "Lines": + { + int thickness; + lock (rngLock) { thickness = rng.Next(1, 5); } + g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh); + } + break; + case "Text": + { + int fontSize; + lock (rngLock) { fontSize = rng.Next(8, 24); } + using Font font = new("Arial", fontSize); + g.DrawString("Mg", font, brush, x, y); + } + break; + default: + { + int shape, thickness, fontSize; + lock (rngLock) + { + shape = rng.Next(0, 4); + thickness = rng.Next(1, 5); + fontSize = rng.Next(8, 24); + } + if (shape == 0) + { + g.FillRectangle(brush, x, y, rw, rh); + } + else if (shape == 1) + { + g.FillEllipse(brush, x, y, rw, rh); + } + else if (shape == 2) + { + g.DrawLine(new Pen(brush, thickness), x, y, x + rw, y + rh); + } + else + { + using Font font = new("Arial", fontSize); + g.DrawString("Mg", font, brush, x, y); + } + } + break; + } + } + + fpsCounter.Value = fps.ToString(); + + return bmp; + } +} + +#endif \ No newline at end of file diff --git a/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs b/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs new file mode 100644 index 0000000..252b6c8 --- /dev/null +++ b/src/DesktopMagic/BuiltInPlugins/SkiaBenchmarkPlugin.cs @@ -0,0 +1,142 @@ +using DesktopMagic.Api; +using DesktopMagic.Api.Settings; + +using SkiaSharp; + +using System; +using System.Drawing; + +namespace DesktopMagic.BuiltInPlugins; + +#if DEBUG + +internal class SkiaBenchmarkPlugin : SkiaPlugin +{ + private readonly Random rng = new(42); + private readonly object rngLock = new(); + + private readonly System.Diagnostics.Stopwatch frameTimer = System.Diagnostics.Stopwatch.StartNew(); + private double fps; + private long lastFrameMs; + + [Setting("skia-shape-count", "Shapes")] + private readonly IntegerUpDown shapeCount = new(10, 10000, 1000); + + [Setting("skia-shape-type", "Shape Type")] + private readonly ComboBox shapeType = new("Rectangles", "Ellipses", "Lines", "Text", "Mixed"); + + [Setting("skia-fps-counter", "Fps")] + private readonly Label fpsCounter = new(""); + + public override int UpdateInterval => 1; + + private SKSurface? surface; + + public override void Start() + { + Size pixelSize = Application.WindowPixelSize; + int w = Math.Max(1, pixelSize.Width); + int h = Math.Max(1, pixelSize.Height); + surface = SKSurface.Create(new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Premul)); + } + + public override SKImage? Main() + { + long now = frameTimer.ElapsedMilliseconds; + if (lastFrameMs > 0) + { + double elapsed = now - lastFrameMs; + fps = (fps * 0.9) + (1000.0 / Math.Max(elapsed, 1) * 0.1); + } + lastFrameMs = now; + + // Render at the full device-pixel window size so the benchmark measures + // real on-screen resolution instead of being scaled down. + Size pixelSize = Application.WindowPixelSize; + int w = Math.Max(1, pixelSize.Width); + int h = Math.Max(1, pixelSize.Height); + + SKCanvas canvas = surface.Canvas; + + canvas.Clear(SKColors.Transparent); + + SKColor color = new( + Application.Theme.PrimaryColor.R, + Application.Theme.PrimaryColor.G, + Application.Theme.PrimaryColor.B, + Application.Theme.PrimaryColor.A); + + int count = shapeCount.Value; + string type = shapeType.Value; + + for (int i = 0; i < count; i++) + { + int x, y, rw, rh; + lock (rngLock) + { + x = rng.Next(0, w); + y = rng.Next(0, h); + rw = rng.Next(5, 60); + rh = rng.Next(5, 60); + } + + byte alpha = (byte)rng.Next(100, 255); + using SKPaint paint = new SKPaint + { + Color = color.WithAlpha(alpha), + IsAntialias = false, + Style = SKPaintStyle.Fill + }; + + switch (type) + { + case "Rectangles": + canvas.DrawRect(x, y, rw, rh, paint); + break; + case "Ellipses": + canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint); + break; + case "Lines": + paint.Style = SKPaintStyle.Stroke; + paint.StrokeWidth = 2; + canvas.DrawLine(x, y, x + rw, y + rh, paint); + break; + case "Text": + using (SKFont textFont = new SKFont(SKTypeface.Default, 16)) + { + canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont, paint); + } + break; + default: + int shape; + lock (rngLock) { shape = rng.Next(0, 4); } + if (shape == 0) + { + canvas.DrawRect(x, y, rw, rh, paint); + } + else if (shape == 1) + { + canvas.DrawOval(x + (rw / 2f), y + (rh / 2f), rw / 2f, rh / 2f, paint); + } + else if (shape == 2) + { + paint.Style = SKPaintStyle.Stroke; + paint.StrokeWidth = 2; + canvas.DrawLine(x, y, x + rw, y + rh, paint); + } + else + { + using SKFont textFont2 = new SKFont(SKTypeface.Default, 16); + canvas.DrawText("Mg", x, y + 16, SKTextAlign.Left, textFont2, paint); + } + break; + } + } + + fpsCounter.Value = fps.ToString(); + + return surface.Snapshot(); + } +} + +#endif \ No newline at end of file diff --git a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs index c8030be..2019c9d 100644 --- a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs @@ -1,62 +1,56 @@ using DesktopMagic.Api; using DesktopMagic.Api.Settings; +using SkiaSharp; + using System; -using System.Drawing; -using System.Drawing.Text; namespace DesktopMagic.BuiltInPlugins; -internal class TimePlugin : Plugin +internal class TimePlugin : SkiaPlugin { + private const float FontSize = 200; + private const float ContentPadding = 8; + [Setting("display-seconds", "Show Seconds")] private readonly CheckBox displaySecondsCheckBox = new CheckBox(true); - private string oldTime = string.Empty; - private bool themeChanged; - public override int UpdateInterval => 1000; - public override Bitmap? Main() + public override SKImage? Main() { - string time = displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString(); + string time = GetTime(); - if (oldTime == time && !themeChanged) + using SKTypeface typeface = SKTypeface.FromFamilyName(Application.Theme.Font); + using SKFont font = new(typeface, FontSize); + using SKPaint paint = new() { - return null; - } + Color = new SKColor( + Application.Theme.PrimaryColor.R, + Application.Theme.PrimaryColor.G, + Application.Theme.PrimaryColor.B, + Application.Theme.PrimaryColor.A), + IsAntialias = true + }; - oldTime = time; - themeChanged = false; + float textWidth = font.MeasureText(time, paint); + SKFontMetrics metrics = font.Metrics; + float textHeight = metrics.Descent - metrics.Ascent; - using Font font = new Font(Application.Theme.Font, 200); + int width = (int)Math.Ceiling(textWidth) + (int)(ContentPadding * 2); + int height = (int)Math.Ceiling(textHeight) + (int)(ContentPadding * 2); - Bitmap bmp = new Bitmap(1, 1); - bmp.SetResolution(100, 100); - using Graphics tmpGr = Graphics.FromImage(bmp); - tmpGr.TextRenderingHint = TextRenderingHint.AntiAlias; + using SKSurface surface = SKSurface.Create(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul)); + surface.Canvas.Clear(SKColors.Transparent); - SizeF size = tmpGr.MeasureString(time, font); + // Draw the text at a fixed size into a content-sized image; the window scales it to fit. + surface.Canvas.DrawText(time, ContentPadding, -metrics.Ascent + ContentPadding, SKTextAlign.Left, font, paint); - bmp = new Bitmap((int)size.Width, (int)size.Height); - 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, brush, 0, 0); - - return bmp; + return surface.Snapshot(); } - public override void OnThemeChanged() + private string GetTime() { - themeChanged = true; + return displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString(); } - - 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 index 722cd2c..929e87f 100644 --- a/src/DesktopMagic/BuiltInPlugins/WeatherPlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/WeatherPlugin.cs @@ -58,9 +58,6 @@ public class WeatherPlugin : AsyncPlugin showTime.OnValueChanged += Application.UpdateWindow; fontSizeSlider.OnValueChanged += Application.UpdateWindow; - await UpdateLocationAndWeather(); - Application.UpdateWindow(); - searchButton.OnClick += () => { isLoading = true; @@ -73,6 +70,9 @@ public class WeatherPlugin : AsyncPlugin Application.UpdateWindow(); }); }; + + await UpdateLocationAndWeather(); + Application.UpdateWindow(); } public override async Task MainAsync(CancellationToken cancellationToken) diff --git a/src/DesktopMagic/Controls/ScreenSelector.xaml b/src/DesktopMagic/Controls/ScreenSelector.xaml new file mode 100644 index 0000000..742afbe --- /dev/null +++ b/src/DesktopMagic/Controls/ScreenSelector.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Controls/ScreenSelector.xaml.cs b/src/DesktopMagic/Controls/ScreenSelector.xaml.cs new file mode 100644 index 0000000..5ea791d --- /dev/null +++ b/src/DesktopMagic/Controls/ScreenSelector.xaml.cs @@ -0,0 +1,182 @@ +using DesktopMagic.DataContexts; + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Drawing; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace DesktopMagic.Controls; + +/// +/// Visual screen selector showing the connected monitors as a mini-map +/// scaled to the control's size while preserving their relative positions. +/// +public partial class ScreenSelector : UserControl +{ + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register(nameof(ItemsSource), typeof(IEnumerable), typeof(ScreenSelector), + new PropertyMetadata(null, OnItemsSourceChanged)); + + public IEnumerable? ItemsSource + { + get => (IEnumerable?)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public static readonly DependencyProperty SelectedItemProperty = + DependencyProperty.Register(nameof(SelectedItem), typeof(ScreenDisplay), typeof(ScreenSelector), + new PropertyMetadata(null, OnSelectedItemChanged)); + + public ScreenDisplay? SelectedItem + { + get => (ScreenDisplay?)GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + private INotifyCollectionChanged? notifySource; + + /// + /// Raised when the user picks a screen by clicking it. + /// + public event Action? SelectionChanged; + + public ScreenSelector() + { + InitializeComponent(); + } + + private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ScreenSelector control = (ScreenSelector)d; + control.AttachSource(); + control.UpdateCanvas(); + control.UpdateSelection(); + } + + private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((ScreenSelector)d).UpdateSelection(); + } + + private void AttachSource() + { + if (notifySource is INotifyCollectionChanged oldSource) + { + oldSource.CollectionChanged -= Source_CollectionChanged; + } + + notifySource = ItemsSource as INotifyCollectionChanged; + if (notifySource is not null) + { + notifySource.CollectionChanged += Source_CollectionChanged; + } + } + + private void Source_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateCanvas(); + UpdateSelection(); + } + + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + // Re-attach in case the control was unloaded and the source instance changed. + AttachSource(); + UpdateCanvas(); + } + + private void UserControl_SizeChanged(object sender, SizeChangedEventArgs e) + { + UpdateCanvas(); + } + + private void UserControl_Unloaded(object sender, RoutedEventArgs e) + { + if (notifySource is INotifyCollectionChanged source) + { + source.CollectionChanged -= Source_CollectionChanged; + } + + notifySource = null; + } + + /// + /// Scales the real screen bounds so they fit the control while keeping their + /// relative positions (including negative coordinates), then centers them. + /// + private void UpdateCanvas() + { + if (ItemsSource is null || ActualWidth <= 0 || ActualHeight <= 0) + { + return; + } + + List screens = ItemsSource.Cast().ToList(); + if (screens.Count == 0) + { + return; + } + + // Union of all monitor bounds (virtual screen space, negative coords kept). + Rectangle totalBounds = new Rectangle(); + foreach (var item in screens) + { + totalBounds = Rectangle.Union(totalBounds, item.Bounds); + } + + // Uniform scale factor + a little margin so the mini-map never touches the edge. + double factor = Math.Max(totalBounds.Height / ActualHeight, totalBounds.Width / ActualWidth) + 2; + + foreach (var item in screens) + { + item.X = item.Bounds.Left / factor; + item.Y = item.Bounds.Top / factor; + item.Width = item.Bounds.Width / factor; + item.Height = item.Bounds.Height / factor; + } + + // Center the whole arrangement in the control. + double minLeft = screens.Min(item => item.X); + double maxRight = screens.Max(item => item.X + item.Width); + double minTop = screens.Min(item => item.Y); + double maxBottom = screens.Max(item => item.Y + item.Height); + + double horizontalOffset = ((maxRight + minLeft) / 2) - (ActualWidth / 2); + double verticalOffset = ((maxBottom + minTop) / 2) - (ActualHeight / 2); + + foreach (var item in screens) + { + item.X -= horizontalOffset; + item.Y -= verticalOffset; + } + } + + private void UpdateSelection() + { + if (ItemsSource is null) + { + return; + } + + foreach (ScreenDisplay item in ItemsSource) + { + item.IsSelected = item == SelectedItem; + } + } + + private void Screen_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (sender is FrameworkElement element && element.DataContext is ScreenDisplay screen) + { + SelectedItem = screen; + SelectionChanged?.Invoke(); + } + + e.Handled = true; + } +} diff --git a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs index d99941a..647c9b7 100644 --- a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs +++ b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs @@ -1,7 +1,10 @@ using DesktopMagic.Helpers; using DesktopMagic.Settings; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; +using System.Linq; using System.Runtime.CompilerServices; namespace DesktopMagic.DataContexts; @@ -11,8 +14,11 @@ internal class MainWindowDataContext : INotifyPropertyChanged public event PropertyChangedEventHandler? PropertyChanged; private static DesktopMagicSettings settings = new(); + private static string? selectedScreenDeviceName; + private string? selectedLayoutName; private bool isLoading = true; + private bool isSharing; private string? pluginsSearchText; public string Title => @@ -24,6 +30,19 @@ internal class MainWindowDataContext : INotifyPropertyChanged public string AppName => App.AppName; + /// + /// Whether a layout or theme is currently being uploaded to mod.io. + /// + public bool IsSharing + { + get => isSharing; + set + { + isSharing = value; + OnPropertyChanged(); + } + } + public DesktopMagicSettings Settings { get => settings; @@ -34,6 +53,43 @@ internal class MainWindowDataContext : INotifyPropertyChanged } } + public ObservableCollection Screens { get; } = []; + + public string? SelectedScreenId + { + get => selectedScreenDeviceName; + set + { + selectedScreenDeviceName = value; + UpdateSelection(); + } + } + + public ScreenDisplay? SelectedScreen + { + get => Screens.FirstOrDefault(screen => screen.DeviceName == SelectedScreenId); + set + { + if (value is not null) + { + SelectedScreenId = value.DeviceName; + } + OnPropertyChanged(); + } + } + + public Layout SelectedLayout => Manager.Instance.SelectedLayout; + + public string? SelectedLayoutName + { + get => selectedLayoutName; + set + { + selectedLayoutName = value; + OnPropertyChanged(); + } + } + public bool IsLoading { get => isLoading; @@ -76,8 +132,163 @@ internal class MainWindowDataContext : INotifyPropertyChanged return settings; } + /// + /// Refreshes the list of detected screens and ensures a screen is selected. + /// + public void RefreshScreens() + { + List allScreens = ScreenUtilities.GetAllScreens(); + Dictionary physicalBounds = ScreenUtilities.GetDisplayPhysicalBounds(); + + // Capture the hardware id of the currently selected screen so the selection can be + // preserved across display changes (e.g. unplug/replug renumbers device names). + ScreenDisplay? previousSelected = SelectedScreen; + + Screens.Clear(); + for (int i = 0; i < allScreens.Count; i++) + { + System.Windows.Forms.Screen screen = allScreens[i]; + physicalBounds.TryGetValue(screen.DeviceName, out System.Drawing.Rectangle physicalBoundsRect); + Screens.Add(new ScreenDisplay(screen, i, physicalBoundsRect, ScreenUtilities.GetMonitorHardwareId(screen))); + } + + if (selectedScreenDeviceName is null || !Screens.Any(screen => screen.DeviceName == selectedScreenDeviceName)) + { + ScreenDisplay? byHardwareId = previousSelected is null + ? null + : Screens.FirstOrDefault(screen => screen.HardwareId == previousSelected.HardwareId); + SelectedScreenId = byHardwareId?.DeviceName ?? Screens.FirstOrDefault()?.DeviceName; + } + else + { + UpdateSelection(); + } + } + + /// + /// Raises change notifications for the currently selected screen's layout. + /// + public void RefreshSelection() + { + UpdateSelection(); + } + + /// + /// Keeps the manager's screen selection in sync and raises notifications for the selected layout. + /// + private void UpdateSelection() + { + Manager.Instance.SelectedScreenDeviceName = selectedScreenDeviceName; + selectedLayoutName = Manager.Instance.SelectedLayout.Name; + OnPropertyChanged(nameof(SelectedScreenId)); + OnPropertyChanged(nameof(SelectedScreen)); + OnPropertyChanged(nameof(SelectedLayout)); + OnPropertyChanged(nameof(SelectedLayoutName)); + } + protected void OnPropertyChanged([CallerMemberName] string? name = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } +} + +/// +/// A detected screen displayed in the UI. +/// +public class ScreenDisplay : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + private bool isSelected; + private double x; + private double y; + private double width; + private double height; + + public ScreenDisplay(System.Windows.Forms.Screen screen, int index, System.Drawing.Rectangle physicalBounds, string hardwareId) + { + DeviceName = screen.DeviceName; + DisplayName = ScreenUtilities.GetScreenLabel(screen, index); + Bounds = physicalBounds.Width > 0 && physicalBounds.Height > 0 ? physicalBounds : screen.Bounds; + IsPrimary = screen.Primary; + Index = index + 1; + HardwareId = hardwareId; + } + + public string DeviceName { get; } + + /// + /// Stable hardware identifier of the monitor (see ), + /// used to persist screen bindings across display changes. + /// + public string HardwareId { get; } + + public string DisplayName { get; } + + public System.Drawing.Rectangle Bounds { get; } + + public bool IsPrimary { get; } + + public int Index { get; } + + public string ToolTipText => IsPrimary ? $"{DisplayName} · Primary" : DisplayName; + + /// + /// Whether this screen is currently selected in the screen selector. + /// + public bool IsSelected + { + get => isSelected; + set + { + isSelected = value; + OnPropertyChanged(); + } + } + + // Normalized (control-local) position/size set by the screen selector. + public double X + { + get => x; + set + { + x = value; + OnPropertyChanged(); + } + } + + public double Y + { + get => y; + set + { + y = value; + OnPropertyChanged(); + } + } + + public double Width + { + get => width; + set + { + width = value; + OnPropertyChanged(); + } + } + + public double Height + { + get => height; + set + { + height = value; + OnPropertyChanged(); + } + } + + private void OnPropertyChanged([CallerMemberName] string? name = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } } \ No newline at end of file diff --git a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs index 896985a..c5c4b64 100644 --- a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs +++ b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs @@ -17,9 +17,38 @@ internal class PluginManagerDataContext : INotifyPropertyChanged private bool isLoading = true; private bool isSearching; private bool isAuthenticated = false; + private ModCategory category = ModCategory.Plugins; + public ObservableCollection AllPlugins { get; } = []; public ObservableCollection InstalledPlugins { get; } = []; + // Layouts or themes, depending on the selected category. + public ObservableCollection AllShared { get; } = []; + public ObservableCollection InstalledShared { get; } = []; + + public ModCategory Category + { + get => category; + set + { + category = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CategoryIndex)); + OnPropertyChanged(nameof(ShownAll)); + OnPropertyChanged(nameof(ShownInstalled)); + } + } + + public int CategoryIndex + { + get => (int)category; + set => Category = (ModCategory)value; + } + + public ObservableCollection ShownAll => category == ModCategory.Plugins ? AllPlugins : AllShared; + + public ObservableCollection ShownInstalled => category == ModCategory.Plugins ? InstalledPlugins : InstalledShared; + public bool IsLoading { get => isLoading; @@ -97,4 +126,11 @@ internal class PluginManagerDataContext : INotifyPropertyChanged { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } -} \ No newline at end of file +} + +internal enum ModCategory +{ + Plugins, + Layouts, + Themes +} diff --git a/src/DesktopMagic/DesktopMagic.csproj b/src/DesktopMagic/DesktopMagic.csproj index 9141158..ec4c04a 100644 --- a/src/DesktopMagic/DesktopMagic.csproj +++ b/src/DesktopMagic/DesktopMagic.csproj @@ -7,11 +7,11 @@ icon.ico OnBuildSuccess Stone_Red - 1.3.3.0 + 1.4.0.0 https://github.com/Stone-Red-Code/DesktopMagic https://github.com/Stone-Red-Code/DesktopMagic - 1.3.3.0 - 1.3.3.0 + 1.4.0.0 + 1.4.0.0 net8.0-windows7.0 win-x64 enable @@ -49,8 +49,8 @@ - + @@ -59,6 +59,7 @@ + diff --git a/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml new file mode 100644 index 0000000..c3cbc70 --- /dev/null +++ b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs new file mode 100644 index 0000000..c9ba581 --- /dev/null +++ b/src/DesktopMagic/Dialogs/ScreenSelectorDialog.xaml.cs @@ -0,0 +1,26 @@ +using DesktopMagic.DataContexts; + +using System.Windows; + +namespace DesktopMagic.Dialogs; + +/// +/// Modal picker for selecting a screen from a visual monitor layout. +/// Closes itself once a screen has been picked. +/// +public partial class ScreenSelectorDialog : Wpf.Ui.Controls.FluentWindow +{ + internal ScreenSelectorDialog(MainWindowDataContext dataContext, string title = App.AppName) + { + InitializeComponent(); + + Resources.MergedDictionaries.Add(App.LanguageDictionary); + + DataContext = dataContext; + titleBar.Title = title; + Title = title; + + // Close the dialog once a screen has been picked. + screenSelector.SelectionChanged += () => DialogResult = true; + } +} diff --git a/src/DesktopMagic/Helpers/ModIoService.cs b/src/DesktopMagic/Helpers/ModIoService.cs new file mode 100644 index 0000000..3061364 --- /dev/null +++ b/src/DesktopMagic/Helpers/ModIoService.cs @@ -0,0 +1,725 @@ +using DesktopMagic.Dialogs; +using DesktopMagic.Plugins; +using DesktopMagic.Settings; + +using Modio; +using Modio.Filters; +using Modio.Models; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using System.Windows; + +using File = System.IO.File; +using Path = System.IO.Path; + +namespace DesktopMagic.Helpers; + +/// +/// Shared mod.io access: authentication and sharing/syncing of layouts and themes. +/// +internal static class ModIoService +{ + public const uint GameId = 5665; + public const string ApiKey = "88e6ea774c3a502b06114e7fee0829ac"; + + private const string Source = "ModIo"; + private const int MaxSummaryLength = 250; + + private static readonly HttpClient httpClient = new(); + private static (string Token, uint UserId)? currentUser; + + public static bool IsAuthenticated => AppSettings.ModIoAccessToken is not null; + + private static DesktopMagicSettings AppSettings => Manager.Instance.Settings; + + public static Client CreateClient() + { + Client.Builder builder = CreateBuilder(); + + if (AppSettings.ModIoAccessToken is string token) + { + _ = builder.WithToken(token); + } + + return builder.Build(); + } + + /// + /// The shared api.mod.io host is deprecated, requests have to go to the game specific host. + /// + private static Client.Builder CreateBuilder() + { + return Client.GetBuilder(ApiKey).WithGameHost(GameId); + } + + /// + /// Whether the mod is a plugin (as opposed to a shared layout or theme). + /// + public static bool IsPluginMod(Mod mod) + { + return !mod.Tags.Any(tag => tag.Name is SharePackage.LayoutTag or SharePackage.ThemeTag); + } + + /// + /// Runs the e-mail security code login. Returns false when cancelled. + /// + public static async Task LogInAsync(Window? owner, string title) + { + App.Logger.LogInfo("Starting mod.io authentication", source: Source); + Client client = CreateBuilder().Build(); + + InputDialog inputDialog = new(GetString("enterModIoEmail"), title) + { + Owner = owner, + }; + + if (inputDialog.ShowDialog() != true) + { + App.Logger.LogInfo("Authentication cancelled by user", source: Source); + return false; + } + + await client.Auth.RequestCode(ApiKey, inputDialog.ResponseText); + + inputDialog = new(GetString("enterModIoAccessToken"), title) + { + Owner = owner, + }; + + if (inputDialog.ShowDialog() != true) + { + App.Logger.LogInfo("Authentication cancelled by user", source: Source); + return false; + } + + AccessToken accessToken = await client.Auth.SecurityCode(ApiKey, inputDialog.ResponseText); + if (accessToken.Value is null) + { + return false; + } + + AppSettings.ModIoAccessToken = accessToken.Value; + Manager.Instance.SaveSettings(); + App.Logger.LogInfo("Successfully authenticated with mod.io", source: Source); + return true; + } + + public static void LogOut() + { + App.Logger.LogInfo("Logging out from mod.io", source: Source); + AppSettings.ModIoAccessToken = null; + currentUser = null; + Manager.Instance.SaveSettings(); + } + + /// + /// Compares the local content with the last published or downloaded version. + /// + public static void RefreshShareState(IModIoShareable item) + { + item.ShareState = item.ModIo switch + { + null => ModIoShareState.NotShared, + { IsOwner: false } => ModIoShareState.NotOwner, + { PublishedHash: string hash } when hash == SharePackage.Build(item).Hash => ModIoShareState.UpToDate, + _ => ModIoShareState.Modified, + }; + } + + /// + /// Publishes the item as a new mod, uploads a new version of the linked mod, or opens the mod page when nothing changed. + /// + /// Shows or hides the caller's waiting overlay while the upload runs. + public static async Task ShareAsync(IModIoShareable item, Window? owner, Action setBusy) + { + if (item is Layout { Name: Manager.EmptyLayoutName }) + { + await ShowMessageAsync(GetString("cannotShareEmptyLayout")); + return; + } + + RefreshShareState(item); + + if (item.ShareState == ModIoShareState.UpToDate) + { + OpenProfile(item.ModIo?.ProfileUri); + return; + } + + if (!IsAuthenticated && !await LogInAsync(owner, App.AppName)) + { + return; + } + + SharePackage.Package package = SharePackage.Build(item); + bool isUpdate = item.ModIo is { IsOwner: true }; + + string? summary = null; + string? changelog = null; + + if (isUpdate) + { + InputDialog changelogDialog = new(GetString("enterModIoChangelog")) { Owner = owner }; + if (changelogDialog.ShowDialog() != true) + { + return; + } + + changelog = changelogDialog.ResponseText.Trim(); + } + else + { + string warning = GetString(item is Layout ? "modIoShareLayoutWarning" : "modIoShareThemeWarning"); + if (package.LocalPlugins.Count > 0) + { + warning += Environment.NewLine + Environment.NewLine + string.Format(GetString("modIoLocalPluginsWarning"), string.Join(", ", package.LocalPlugins)); + } + + if (!await ConfirmAsync(warning, GetString("shareOnModIo"))) + { + return; + } + + InputDialog summaryDialog = new(GetString("enterModIoSummary")) { Owner = owner }; + if (summaryDialog.ShowDialog() != true) + { + return; + } + + summary = summaryDialog.ResponseText.Trim(); + if (string.IsNullOrWhiteSpace(summary)) + { + summary = item.Name; + } + + if (summary.Length > MaxSummaryLength) + { + summary = summary[..MaxSummaryLength]; + } + } + + string workingDirectory = Path.Combine(Path.GetTempPath(), App.AppName, Guid.NewGuid().ToString()); + setBusy(true); + + try + { + _ = Directory.CreateDirectory(workingDirectory); + + string contentDirectory = Path.Combine(workingDirectory, "content"); + _ = Directory.CreateDirectory(contentDirectory); + await File.WriteAllTextAsync(Path.Combine(contentDirectory, SharePackage.ContentFileName), package.Json.ToJsonString()); + + string zipPath = Path.Combine(workingDirectory, "content.zip"); + ZipFile.CreateFromDirectory(contentDirectory, zipPath); + + string logoPath = Path.Combine(workingDirectory, "logo.png"); + SharePreviewRenderer.Render(item, logoPath); + + Client client = CreateClient(); + ModIoLink link; + + if (isUpdate) + { + link = item.ModIo!; + } + else + { + App.Logger.LogInfo($"Creating mod.io mod for \"{item.Name}\"", source: Source); + NewMod newMod = new(item.Name, summary!, new FileInfo(logoPath)) + { + Visible = Modio.Models.Visibility.Public, + }; + newMod.Tags.Add(SharePackage.GetTag(item)); + + Mod mod = await client.Games[GameId].Mods.Add(newMod); + + // A downloaded item shared as a new mod no longer follows the original, which would otherwise be downloaded again. + if (item.ModIo is { IsOwner: false } originalLink) + { + await UnsubscribeAsync(originalLink.ModId); + } + + // Save the link right away, so a failed upload is retried on the same mod instead of creating another one. + link = new ModIoLink + { + ModId = mod.Id, + IsOwner = true, + ProfileUri = mod.ProfileUrl, + }; + item.ModIo = link; + Manager.Instance.SaveSettings(); + } + + App.Logger.LogInfo($"Uploading new version of \"{item.Name}\" (mod {link.ModId})", source: Source); + Modio.Models.File modfile = await client.Games[GameId].Mods[link.ModId].Files.Add(new NewFile(new FileInfo(zipPath)) + { + Version = DateTime.UtcNow.ToString("yyyy.MM.dd.HHmm"), + Changelog = string.IsNullOrWhiteSpace(changelog) ? null : changelog, + Active = true, + }, new UploadOptions()); + + link.ModfileId = modfile.Id; + link.PublishedHash = package.Hash; + Manager.Instance.SaveSettings(); + + if (isUpdate) + { + await TryUpdateLogo(client, link.ModId, logoPath); + } + + if (item is Layout) + { + await TryUpdateDependencies(client, link.ModId, package.RequiredPlugins); + } + + RefreshShareState(item); + App.Logger.LogInfo($"Published \"{item.Name}\" on mod.io", source: Source); + + setBusy(false); + if (await ConfirmAsync(string.Format(GetString(isUpdate ? "modIoUpdateSuccess" : "modIoShareSuccess"), item.Name), GetString("open"), GetString("close"))) + { + OpenProfile(link.ProfileUri); + } + } + catch (UnauthorizedException ex) + { + App.Logger.LogError($"Sharing \"{item.Name}\" failed, not authorized: {ex.Message}", source: Source); + LogOut(); + setBusy(false); + await ShowMessageAsync(GetString("modIoSessionExpired")); + } + catch (Exception ex) + { + App.Logger.LogError($"Sharing \"{item.Name}\" failed: {ex.Message}", source: Source); + setBusy(false); + await ShowMessageAsync(string.Format(GetString("modIoShareFailed"), ex.Message)); + } + finally + { + setBusy(false); + RefreshShareState(item); + TryDeleteDirectory(workingDirectory); + } + } + + /// + /// Unsubscribes from the mod an item is linked to, so deleting it locally does not bring it back on the next sync. + /// + public static async Task UnsubscribeAsync(IModIoShareable item) + { + if (item.ModIo is not null) + { + await UnsubscribeAsync(item.ModIo.ModId); + } + } + + private static async Task UnsubscribeAsync(uint modId) + { + if (!IsAuthenticated) + { + return; + } + + try + { + await CreateClient().Games[GameId].Mods.Unsubscribe(modId); + App.Logger.LogInfo($"Unsubscribed from mod {modId}", source: Source); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to unsubscribe from mod {modId}: {ex.Message}", source: Source); + } + } + + /// + /// Subscribes to the mods of downloaded layouts and themes, e.g. after logging in, + /// so the subscription sync keeps them instead of treating them as unsubscribed. + /// + public static async Task SubscribeToLinkedItemsAsync() + { + if (!IsAuthenticated) + { + return; + } + + Client client = CreateClient(); + + foreach (ModIoLink link in GetSharedItems().Select(item => item.ModIo).OfType().Where(link => !link.IsOwner).ToList()) + { + try + { + await client.Games[GameId].Mods.Subscribe(link.ModId); + App.Logger.LogInfo($"Subscribed to mod {link.ModId}", source: Source); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to subscribe to mod {link.ModId}: {ex.Message}", source: Source); + } + } + } + + public static IEnumerable GetSharedItems() + { + DesktopMagicSettings settings = AppSettings; + return settings.Layouts.Cast().Concat(settings.Themes); + } + + public static IModIoShareable? FindLinkedItem(uint modId) + { + return GetSharedItems().FirstOrDefault(item => item.ModIo?.ModId == modId); + } + + /// + /// Downloads a layout or theme from mod.io and subscribes to it when logged in. + /// + /// Installs the mod.io plugin with the given ID if it is missing. + /// Whether the item was installed. + public static async Task InstallAsync(Mod mod, Func ensurePluginInstalled) + { + try + { + if (!await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled)) + { + return false; + } + + if (IsAuthenticated) + { + try + { + await CreateClient().Games[GameId].Mods.Subscribe(mod.Id); + App.Logger.LogInfo($"Subscribed to mod {mod.Id}", source: Source); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to subscribe to mod {mod.Id}: {ex.Message}", source: Source); + } + } + + Manager.Instance.SaveSettings(); + return true; + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source); + await ShowMessageAsync(string.Format(GetString("modIoInstallFailed"), mod.Name, ex.Message)); + return false; + } + } + + /// + /// Updates a linked layout or theme when mod.io has a newer version. + /// + /// Whether the item changed. + public static async Task UpdateAsync(IModIoShareable item, Mod mod, Func ensurePluginInstalled) + { + try + { + if (!await ApplyLatestVersionAsync(mod, item, ensurePluginInstalled)) + { + return false; + } + + Manager.Instance.SaveSettings(); + return true; + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to update \"{item.Name}\" (mod {mod.Id}): {ex.Message}", source: Source); + return false; + } + } + + /// + /// Deletes a layout or theme locally after confirmation and unsubscribes from its mod. + /// The mod itself stays on mod.io. + /// + /// Whether the item was removed. + public static async Task RemoveAsync(IModIoShareable item) + { + DesktopMagicSettings settings = AppSettings; + + string? blocker = item switch + { + Layout { Name: Manager.EmptyLayoutName } => "cannotDeleteEmptyLayout", + Layout when settings.Layouts.Count <= 1 => "cannotDeleteLastLayout", + Theme when settings.Themes.Count <= 1 => "cannotDeleteLastTheme", + _ => null + }; + + if (blocker is not null) + { + await ShowMessageAsync(GetString(blocker)); + return false; + } + + if (!await ConfirmAsync(GetString(item is Layout ? "confirmDeleteLayout" : "confirmDeleteTheme"), "Yes", "No")) + { + return false; + } + + await UnsubscribeAsync(item); + + switch (item) + { + case Layout layout: + Manager.Instance.RemoveLayout(layout); + break; + + case Theme theme: + _ = settings.Themes.Remove(theme); + Manager.Instance.SaveSettings(); + break; + } + + App.Logger.LogInfo($"Removed \"{item.Name}\"", source: Source); + return true; + } + + /// + /// Downloads subscribed layouts and themes that are new or have a newer version on mod.io. + /// + /// The user's subscribed mods for this game. + /// Installs the mod.io plugin with the given ID if it is missing. + /// Whether any layout or theme changed. + public static async Task SyncSubscriptionsAsync(IReadOnlyList subscriptions, Func ensurePluginInstalled) + { + if (!IsAuthenticated) + { + return false; + } + + bool changed = false; + + List sharedMods = subscriptions.Where(mod => !IsPluginMod(mod)).ToList(); + App.Logger.LogInfo($"Found {sharedMods.Count} subscribed layouts and themes on mod.io", source: Source); + + foreach (Mod mod in sharedMods) + { + try + { + changed |= await ApplyLatestVersionAsync(mod, FindLinkedItem(mod.Id), ensurePluginInstalled); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to install \"{mod.Name}\" (mod {mod.Id}): {ex.Message}", source: Source); + } + } + + // Downloaded items whose subscription was removed on mod.io stay as local copies. + foreach (IModIoShareable item in GetSharedItems().Where(item => item.ModIo is { IsOwner: false } && !subscriptions.Any(mod => mod.Id == item.ModIo.ModId)).ToList()) + { + App.Logger.LogInfo($"\"{item.Name}\" is no longer subscribed, keeping it as a local copy", source: Source); + item.ModIo = null; + RefreshShareState(item); + changed = true; + } + + if (changed) + { + Manager.Instance.SaveSettings(); + } + + return changed; + } + + /// + /// Downloads the current version of the mod into a new item, or into when it is outdated. + /// Asks before overwriting local changes. Throws when the download or import fails. + /// + /// Whether anything changed. + private static async Task ApplyLatestVersionAsync(Mod mod, IModIoShareable? local, Func ensurePluginInstalled) + { + if (mod.Modfile?.Download?.BinaryUrl is null) + { + throw new InvalidOperationException("The mod has no file to download."); + } + + if (local?.ModIo?.ModfileId == mod.Modfile.Id) + { + return false; + } + + if (local?.ModIo is ModIoLink localLink && localLink.PublishedHash != SharePackage.Build(local).Hash) + { + bool overwrite = await ConfirmAsync(string.Format(GetString("modIoUpdateConflict"), local.Name), GetString("overwrite"), GetString("keepMine")); + + if (!overwrite) + { + App.Logger.LogInfo($"Keeping local changes of \"{local.Name}\" instead of updating to mod {mod.Id} version {mod.Modfile.Id}", source: Source); + + if (localLink.IsOwner) + { + // The owner can publish their version over the remote one. + localLink.ModfileId = mod.Modfile.Id; + } + else + { + // Detach the local copy so it is no longer replaced by updates. + await UnsubscribeAsync(local); + local.ModIo = null; + } + + return true; + } + } + + App.Logger.LogInfo($"Downloading \"{mod.Name}\" (mod {mod.Id}, version {mod.Modfile.Id})", source: Source); + JsonObject content = await DownloadContentAsync(mod.Modfile.Download.BinaryUrl); + + foreach (uint pluginId in SharePackage.GetRequiredPlugins(content).Where(id => !Manager.Instance.Plugins.ContainsKey(id))) + { + await ensurePluginInstalled(pluginId); + } + + uint? userId = IsAuthenticated ? await GetCurrentUserIdAsync(CreateClient()) : null; + + IModIoShareable item = SharePackage.Apply(content, local); + item.ModIo = new ModIoLink + { + ModId = mod.Id, + ModfileId = mod.Modfile.Id, + IsOwner = userId is not null && mod.SubmittedBy?.Id == userId, + ProfileUri = mod.ProfileUrl, + }; + item.ModIo.PublishedHash = SharePackage.Build(item).Hash; + RefreshShareState(item); + + App.Logger.LogInfo($"Installed \"{item.Name}\" from mod.io", source: Source); + return true; + } + + private static async Task DownloadContentAsync(Uri url) + { + using MemoryStream zipStream = new(); + using (Stream download = await httpClient.GetStreamAsync(url)) + { + await download.CopyToAsync(zipStream); + } + + zipStream.Position = 0; + using ZipArchive archive = new(zipStream, ZipArchiveMode.Read); + ZipArchiveEntry entry = archive.GetEntry(SharePackage.ContentFileName) ?? throw new FormatException($"The download does not contain \"{SharePackage.ContentFileName}\"."); + + using StreamReader reader = new(entry.Open()); + return SharePackage.Parse(await reader.ReadToEndAsync()); + } + + private static async Task GetCurrentUserIdAsync(Client client) + { + string token = AppSettings.ModIoAccessToken ?? string.Empty; + + if (currentUser is not { } user || user.Token != token) + { + User me = await client.User.GetCurrentUser(); + user = (token, me.Id); + currentUser = user; + } + + return user.UserId; + } + + private static async Task TryUpdateLogo(Client client, uint modId, string logoPath) + { + try + { + await client.Games[GameId].Mods[modId].AddMedia(new NewModMedia { Logo = new FileInfo(logoPath) }); + } + catch (Exception ex) + { + App.Logger.LogWarn($"Failed to update logo of mod {modId}: {ex.Message}", source: Source); + } + } + + private static async Task TryUpdateDependencies(Client client, uint modId, IReadOnlyList requiredPlugins) + { + try + { + DependenciesClient dependencies = client.Games[GameId].Mods[modId].Dependencies; + List current = (await dependencies.Get(false)).Select(dependency => dependency.ModId).ToList(); + + List removed = current.Except(requiredPlugins).ToList(); + List added = requiredPlugins.Except(current).ToList(); + + if (removed.Count > 0) + { + await dependencies.Delete(removed); + } + + if (added.Count > 0) + { + await dependencies.Add(added); + } + } + catch (Exception ex) + { + App.Logger.LogWarn($"Failed to update dependencies of mod {modId}: {ex.Message}", source: Source); + } + } + + private static void OpenProfile(Uri? profileUri) + { + if (profileUri is null) + { + return; + } + + _ = Process.Start(new ProcessStartInfo + { + UseShellExecute = true, + FileName = profileUri.ToString() + }); + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch (Exception ex) + { + App.Logger.LogWarn($"Failed to delete temporary directory {path}: {ex.Message}", source: Source); + } + } + + private static async Task ShowMessageAsync(string content) + { + Wpf.Ui.Controls.MessageBox messageBox = new() + { + Title = App.AppName, + Content = content, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + } + + private static async Task ConfirmAsync(string content, string confirmText, string? cancelText = null) + { + Wpf.Ui.Controls.MessageBox messageBox = new() + { + Title = App.AppName, + Content = content, + PrimaryButtonText = confirmText, + CloseButtonText = cancelText ?? GetString("cancel"), + }; + + return await messageBox.ShowDialogAsync() == Wpf.Ui.Controls.MessageBoxResult.Primary; + } + + private static string GetString(string key) + { + return App.LanguageDictionary[key] as string ?? key; + } +} diff --git a/src/DesktopMagic/Helpers/OcclusionMonitor.cs b/src/DesktopMagic/Helpers/OcclusionMonitor.cs new file mode 100644 index 0000000..1e515fa --- /dev/null +++ b/src/DesktopMagic/Helpers/OcclusionMonitor.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Threading; + +namespace DesktopMagic.Helpers; + +/// +/// Periodically checks whether plugin windows are occluded by other top-level windows and pauses +/// their rendering while they are (a coverage-based pause algorithm, same idea as Lively's). +/// Pausing stops the plugin update timer, which stops re-rendering and re-uploading frames to the +/// GPU, so fully covered widgets consume almost no resources. +/// +internal sealed class OcclusionMonitor : IDisposable +{ + private const int CheckIntervalMs = 100; + + // Pause only when essentially the whole window is covered so widgets stay responsive even + // when a corner is overlapped by another window. + private const double CoverageThreshold = 0.95; + + private readonly System.Timers.Timer timer = new(CheckIntervalMs); + private readonly Dictionary lastPaused = []; + private readonly object gate = new(); + private bool disposed; + + // (window, handle) pairs of the plugin windows, refreshed asynchronously on the UI thread. + private volatile IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windowSnapshot = []; + private int snapshotRefreshPending; + + public bool IsRunning { get; private set; } + + public void Start() + { + if (IsRunning) + { + return; + } + + timer.Elapsed += Timer_Elapsed; + timer.Start(); + IsRunning = true; + } + + private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + if (disposed) + { + return; + } + + // System.Timers.Timer can re-enter this handler on another ThreadPool thread when the + // previous invocation is still running, so serialize the whole pass. + if (!Monitor.TryEnter(gate)) + { + return; + } + + try + { + RunOcclusionPass(); + } + finally + { + Monitor.Exit(gate); + } + } + + private void RunOcclusionPass() + { + RequestSnapshotRefresh(); + + IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows = windowSnapshot; + HashSet live = [.. windows.Select(item => item.Window)]; + + Dictionary pausedStates; + try + { + pausedStates = ComputePausedStates(windows); + } + catch + { + return; + } + + foreach ((IPluginWindow window, bool paused) in pausedStates) + { + if (lastPaused.TryGetValue(window, out bool previous) && previous == paused) + { + continue; + } + + lastPaused[window] = paused; + + try + { + _ = ((Window)window).Dispatcher.InvokeAsync(() => window.SetRenderPaused(paused)); + } + catch + { + // Window is closing; ignore. + } + } + + // Drop tracking for windows that no longer exist. + foreach (IPluginWindow stale in lastPaused.Keys.Where(key => !live.Contains(key)).ToArray()) + { + _ = lastPaused.Remove(stale); + } + } + + /// + /// Posts a refresh of to the UI thread without waiting for it. + /// The manager mutates PluginWindows on the UI thread and window handles can only be obtained + /// there; a snapshot that is one pass old is good enough, and the timer thread never blocks. + /// + private void RequestSnapshotRefresh() + { + // Skip when the previous refresh has not run yet (e.g. the UI thread is busy). + if (Interlocked.Exchange(ref snapshotRefreshPending, 1) != 0) + { + return; + } + + try + { + _ = Application.Current.Dispatcher.InvokeAsync(() => + { + try + { + windowSnapshot = Manager.Instance.PluginWindows + .Where(window => window.IsRunning) + .Select(window => (Window: window, Hwnd: new WindowInteropHelper((Window)window).Handle)) + .Where(item => item.Hwnd != nint.Zero) + .ToList(); + } + finally + { + _ = Interlocked.Exchange(ref snapshotRefreshPending, 0); + } + }, DispatcherPriority.Background); + } + catch + { + // The application is shutting down. + _ = Interlocked.Exchange(ref snapshotRefreshPending, 0); + } + } + + /// + /// Decides for every plugin window whether it is occluded, using a single top-to-bottom pass + /// over all top-level windows. EnumWindows enumerates in z-order (front to back), so every + /// window seen before a plugin window is above it. The pass stops at the lowest plugin window. + /// + private static Dictionary ComputePausedStates(IReadOnlyList<(IPluginWindow Window, nint Hwnd)> windows) + { + Dictionary pausedStates = []; + + // Plugin windows that have not been reached in z-order yet. + Dictionary remaining = []; + foreach ((IPluginWindow window, nint hwnd) in windows) + { + if (W32.GetWindowRect(hwnd, out W32.RECT rect)) + { + remaining[hwnd] = (window, rect); + } + } + + if (remaining.Count == 0) + { + return pausedStates; + } + + uint currentProcessId = (uint)Environment.ProcessId; + + // Visible windows above the current z-order position that overlap a remaining plugin window. + List occluders = []; + + _ = W32.EnumWindows((hwnd, _) => + { + if (remaining.Remove(hwnd, out (IPluginWindow Window, Rectangle Area) target)) + { + double coverage = ComputeCoverage(target.Area, occluders); + pausedStates[target.Window] = IsForegroundCovering(target.Area, hwnd) || coverage >= CoverageThreshold; + + // Windows below the lowest plugin window cannot occlude anything. + return remaining.Count > 0; + } + + if (TryGetOccluderRect(hwnd, currentProcessId, remaining.Values, out Rectangle occluderRect)) + { + occluders.Add(occluderRect); + } + + return true; + }, nint.Zero); + + return pausedStates; + } + + /// + /// Returns the bounds of when it is a visible window from another process + /// that overlaps at least one of . Cheap checks run first so the more + /// expensive ones only run for windows that actually overlap a plugin window. + /// + private static bool TryGetOccluderRect(nint hwnd, uint currentProcessId, IEnumerable<(IPluginWindow Window, Rectangle Area)> targets, out Rectangle occluderRect) + { + occluderRect = Rectangle.Empty; + + // Only windows from other processes occlude our widgets. + if (!W32.IsWindowVisible(hwnd) + || W32.GetWindowThreadProcessId(hwnd, out uint processId) == 0 + || processId == currentProcessId + || !W32.GetWindowRect(hwnd, out W32.RECT rect)) + { + return false; + } + + Rectangle bounds = rect; + if (!targets.Any(target => target.Area.IntersectsWith(bounds))) + { + return false; + } + + // Only non-cloaked windows are actually visible (exclusive-fullscreen games cloak the desktop). + // Explorer's desktop/wallpaper host windows (WorkerW, Progman, ...) report as visible and span + // the whole screen but never occlude anything. Fully transparent layered overlays (game + // overlays etc.) span the whole screen but cannot be seen either. + if (IsCloaked(hwnd) || IsDesktopShellWindow(hwnd) || IsFullyTransparent(hwnd)) + { + return false; + } + + occluderRect = bounds; + return true; + } + + /// + /// Computes how much of (in virtual screen coordinates) is covered by + /// . + /// + private static double ComputeCoverage(Rectangle target, List occluders) + { + List coveringRects = []; + + foreach (Rectangle occluder in occluders) + { + Rectangle intersection = Rectangle.Intersect(target, occluder); + if (intersection.Width > 0 && intersection.Height > 0) + { + coveringRects.Add(intersection); + } + } + + if (coveringRects.Count == 0 || target.Width <= 0 || target.Height <= 0) + { + return 0; + } + + return UnionArea(coveringRects) / (double)(target.Width * target.Height); + } + + private static bool IsForegroundCovering(Rectangle target, nint selfHwnd) + { + nint foreground = W32.GetForegroundWindow(); + if (foreground == nint.Zero || foreground == selfHwnd) + { + return false; + } + + if (W32.GetWindowThreadProcessId(foreground, out _) == 0) + { + return false; + } + + // A maximized/fullscreen app from another process covering the widget pauses it. + _ = W32.GetWindowRect(foreground, out W32.RECT rect); + Rectangle intersection = Rectangle.Intersect(target, rect); + + return target.Width > 0 && target.Height > 0 + && intersection.Width >= target.Width * CoverageThreshold + && intersection.Height >= target.Height * CoverageThreshold; + } + + private static bool IsCloaked(nint hwnd) + { + return W32.DwmGetWindowAttribute(hwnd, W32.DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0; + } + + /// + /// Returns true for layered windows that are fully transparent (alpha 0), i.e. invisible + /// overlay hosts that span the screen but never visibly occlude anything. + /// + private static bool IsFullyTransparent(nint hwnd) + { + if ((W32.GetWindowLong(hwnd, W32.GWL_EXSTYLE) & W32.WS_EX_LAYERED) == 0) + { + return false; + } + + return W32.GetLayeredWindowAttributes(hwnd, out _, out byte alpha, out int flags) + && (flags & W32.LWA_ALPHA) != 0 + && alpha == 0; + } + + /// + /// Returns true for the Explorer desktop/wallpaper host windows that span the whole screen but + /// never visually occlude anything (wallpaper, hidden desktop icon layer, ...). + /// + private static bool IsDesktopShellWindow(nint hwnd) + { + StringBuilder className = new(256); + _ = W32.GetClassName(hwnd, className, className.Capacity); + + return className.ToString() is "WorkerW" or "Progman" or "SHELLDLL_DefView" or "SysListView32"; + } + + /// + /// Computes the total (union) area of using a y-scanline sweep so + /// overlapping windows are not double counted. + /// + private static long UnionArea(List rects) + { + if (rects.Count == 0) + { + return 0; + } + + List yEdges = []; + foreach (Rectangle rect in rects) + { + yEdges.Add(rect.Top); + yEdges.Add(rect.Bottom); + } + yEdges.Sort(); + + long area = 0; + for (int i = 0; i < yEdges.Count - 1; i++) + { + int y0 = yEdges[i]; + int y1 = yEdges[i + 1]; + if (y0 == y1) + { + continue; + } + + // Collect the x extents of all rects that span this band, then merge them. + List<(int Left, int Right)> xRanges = []; + foreach (Rectangle rect in rects) + { + if (rect.Top <= y0 && rect.Bottom >= y1) + { + xRanges.Add((rect.Left, rect.Right)); + } + } + + if (xRanges.Count == 0) + { + continue; + } + + xRanges.Sort((a, b) => a.Left.CompareTo(b.Left)); + int mergedLeft = xRanges[0].Left; + int mergedRight = xRanges[0].Right; + long bandWidth = 0; + + for (int j = 1; j < xRanges.Count; j++) + { + if (xRanges[j].Left <= mergedRight) + { + mergedRight = Math.Max(mergedRight, xRanges[j].Right); + } + else + { + bandWidth += mergedRight - mergedLeft; + mergedLeft = xRanges[j].Left; + mergedRight = xRanges[j].Right; + } + } + bandWidth += mergedRight - mergedLeft; + + area += bandWidth * (y1 - y0); + } + + return area; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + timer.Elapsed -= Timer_Elapsed; + timer.Stop(); + timer.Dispose(); + } +} \ No newline at end of file diff --git a/src/DesktopMagic/Helpers/ScreenUtilities.cs b/src/DesktopMagic/Helpers/ScreenUtilities.cs new file mode 100644 index 0000000..5a6a7c0 --- /dev/null +++ b/src/DesktopMagic/Helpers/ScreenUtilities.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Windows; + +namespace DesktopMagic.Helpers; + +/// +/// Helpers for enumerating screens and converting between percentage based +/// positions/sizes (relative to a screen's bounds) and absolute positions. +/// All conversions work in WPF DIP space because WPF window , +/// , and +/// are expressed in device independent pixels, while +/// is expressed in physical pixels. Each screen is therefore converted to DIP space +/// using its own DPI scaling factor. +/// +public static class ScreenUtilities +{ + private const uint MonitorDefaultToNearest = 0x00000002; + + private const int MDT_EFFECTIVE_DPI = 0; + + /// + /// Gets the list of all screens currently connected, in the order reported by Windows. + /// + public static List GetAllScreens() + { + return System.Windows.Forms.Screen.AllScreens.ToList(); + } + + /// + /// Gets the screen matching the given device name (e.g. "\\.\DISPLAY1"), or null. + /// + public static System.Windows.Forms.Screen? GetScreenByDeviceName(string? deviceName) + { + if (string.IsNullOrWhiteSpace(deviceName)) + { + return null; + } + + return System.Windows.Forms.Screen.AllScreens.FirstOrDefault(screen => string.Equals(screen.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets the primary screen, or the first available screen as a fallback. + /// + public static System.Windows.Forms.Screen GetPrimaryScreen() + { + return System.Windows.Forms.Screen.PrimaryScreen ?? System.Windows.Forms.Screen.AllScreens.FirstOrDefault() + ?? throw new InvalidOperationException("No screens detected."); + } + + /// + /// Aspect ratio (Width / Height) of the screen's bounds. + /// + public static double GetAspectRatio(System.Windows.Forms.Screen screen) + { + if (screen.Bounds.Height == 0) + { + return 0; + } + + return screen.Bounds.Width / (double)screen.Bounds.Height; + } + + /// + /// Converts a percentage based position (0..1 relative to the screen bounds) to an absolute + /// WPF position (DIPs) on that screen. + /// + public static Point PercentToPosition(Point percent, System.Drawing.Rectangle bounds) + { + Rect dips = GetScreenDips(bounds); + return new Point( + dips.Left + (percent.X * dips.Width), + dips.Top + (percent.Y * dips.Height)); + } + + /// + /// Converts an absolute WPF position (DIPs) to a percentage (0..1) of the screen bounds. + /// + public static Point PositionToPercent(Point position, System.Drawing.Rectangle bounds) + { + Rect dips = GetScreenDips(bounds); + if (dips.Width == 0 || dips.Height == 0) + { + return new Point(0.05, 0.05); + } + + return new Point( + (position.X - dips.Left) / dips.Width, + (position.Y - dips.Top) / dips.Height); + } + + /// + /// Converts a percentage based size (0..1 of the screen bounds) to an absolute WPF size (DIPs). + /// + public static Point PercentSizeToSize(Point percent, System.Drawing.Rectangle bounds) + { + Rect dips = GetScreenDips(bounds); + return new Point( + percent.X * dips.Width, + percent.Y * dips.Height); + } + + /// + /// Converts an absolute WPF size (DIPs) to a percentage (0..1) of the screen bounds. + /// + public static Point SizeToPercent(Point size, System.Drawing.Rectangle bounds) + { + Rect dips = GetScreenDips(bounds); + if (dips.Width == 0 || dips.Height == 0) + { + return new Point(0.3, 0.3); + } + + return new Point( + size.X / dips.Width, + size.Y / dips.Height); + } + + /// + /// Window hook that keeps a window inside the given screen bounds. + /// fires only after a move has already happened, and the system move loop overrides any correction + /// made there on the next mouse move, so the proposed position is adjusted in WM_WINDOWPOSCHANGING + /// instead, which every move and resize goes through. The position and the bounds are both in this + /// process' screen coordinates, so no DIP conversion is needed. + /// + public static IntPtr ClampToScreenHook(IntPtr hwnd, int msg, IntPtr lParam, System.Drawing.Rectangle bounds) + { + if (msg != NativeMethods.WM_WINDOWPOSCHANGING || bounds.Width == 0 || bounds.Height == 0) + { + return IntPtr.Zero; + } + + NativeMethods.WINDOWPOS pos = Marshal.PtrToStructure(lParam); + if ((pos.flags & NativeMethods.SWP_NOMOVE) != 0) + { + return IntPtr.Zero; + } + + int width = pos.cx; + int height = pos.cy; + if ((pos.flags & NativeMethods.SWP_NOSIZE) != 0) + { + if (!W32.GetWindowRect(hwnd, out W32.RECT current)) + { + return IntPtr.Zero; + } + + width = current.Width; + height = current.Height; + } + + int x = width <= bounds.Width ? Math.Clamp(pos.x, bounds.Left, bounds.Right - width) : bounds.Left; + int y = height <= bounds.Height ? Math.Clamp(pos.y, bounds.Top, bounds.Bottom - height) : bounds.Top; + + if (x != pos.x || y != pos.y) + { + pos.x = x; + pos.y = y; + Marshal.StructureToPtr(pos, lParam, false); + } + + return IntPtr.Zero; + } + + /// + /// Builds a human readable label for a screen, e.g. "Display 1 · DELL U2715H · 3840x2160". + /// + public static string GetScreenLabel(System.Windows.Forms.Screen screen, int index) + { + string name = GetFriendlyName(screen); + string resolution = $"{screen.Bounds.Width}x{screen.Bounds.Height}"; + + return string.IsNullOrWhiteSpace(name) || string.Equals(name, "Generic PnP Monitor", StringComparison.OrdinalIgnoreCase) + ? $"Display {index + 1} · {resolution}" + : $"Display {index + 1} · {name} · {resolution}"; + } + + /// + /// Gets the friendly monitor model name (e.g. "DELL U2715H") for the given screen, + /// or an empty string when it cannot be determined. + /// + public static string GetFriendlyName(System.Windows.Forms.Screen screen) + { + return GetMonitorDisplayDevice(screen)?.DeviceString ?? string.Empty; + } + + /// + /// Gets a stable hardware identifier for the screen: the PnP device instance ID of its + /// monitor (e.g. "MONITOR\DEL41F1\{...}\{0001}"), which is derived from the monitor's + /// EDID and survives disconnects and reconnects of the same monitor. When no hardware ID + /// is available (e.g. virtual or remote displays), a deterministic SHA-256 hash of the + /// bounds is used so the identifier is never empty. + /// + public static string GetMonitorHardwareId(System.Windows.Forms.Screen screen) + { + string? deviceId = GetMonitorDisplayDevice(screen)?.DeviceID; + if (!string.IsNullOrWhiteSpace(deviceId)) + { + return deviceId; + } + + System.Drawing.Rectangle bounds = screen.Bounds; + string boundsString = $"{bounds.X}-{bounds.Y}-{bounds.Width}-{bounds.Height}"; + string hashString = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(boundsString))).ToLowerInvariant(); + return $"DISPLAY#{hashString}"; + } + + /// + /// Gets the monitor device info (second-level entry) for + /// the given screen, or null when it cannot be determined. + /// + private static DISPLAY_DEVICE? GetMonitorDisplayDevice(System.Windows.Forms.Screen screen) + { + try + { + DISPLAY_DEVICE device = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; + if (EnumDisplayDevices(screen.DeviceName, 0, ref device, 0)) + { + DISPLAY_DEVICE monitor = new DISPLAY_DEVICE { cb = (uint)Marshal.SizeOf() }; + if (EnumDisplayDevices(device.DeviceName, 0, ref monitor, 0)) + { + return monitor; + } + } + } + catch (Exception) + { + // Fall through to null. + } + + return null; + } + + /// + /// Enumerates monitors in a Per-Monitor V2 DPI awareness context and returns each + /// monitor's bounds in physical (device) pixels on the virtual screen, keyed by device + /// name (e.g. "\\.\DISPLAY1"). This uses the same coordinate space as DPI-aware apps + /// such as Lively Wallpaper, keeping multi-screen layouts with mixed DPI scaling + /// consistent regardless of this app's own DPI awareness mode. + /// + public static Dictionary GetDisplayPhysicalBounds() + { + Dictionary result = new Dictionary(); + + IntPtr prevContext = IntPtr.Zero; + bool contextChanged = false; + try + { + prevContext = SetThreadDpiAwarenessContext((IntPtr)DpiAwarenessContextPerMonitorV2); + contextChanged = prevContext != IntPtr.Zero; + } + catch (Exception) + { + // SetThreadDpiAwarenessContext unavailable; fall back to default context. + } + + try + { + _ = EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, (hMonitor, hdcMonitor, lprcMonitor, dwData) => + { + MONITORINFOEX info = new MONITORINFOEX { cbSize = (uint)Marshal.SizeOf() }; + if (GetMonitorInfo(hMonitor, ref info)) + { + string deviceName = info.szDevice.TrimEnd('\0'); + result[deviceName] = new System.Drawing.Rectangle( + info.rcMonitor.Left, info.rcMonitor.Top, + info.rcMonitor.Right - info.rcMonitor.Left, + info.rcMonitor.Bottom - info.rcMonitor.Top); + } + + return true; + }, IntPtr.Zero); + } + finally + { + if (contextChanged) + { + _ = SetThreadDpiAwarenessContext(prevContext); + } + } + + return result; + } + + /// + /// Gets the screen bounds converted to WPF DIP space using the screen's own DPI scaling factor. + /// + private static Rect GetScreenDips(System.Drawing.Rectangle bounds) + { + double scale = GetDpiScale(bounds); + return new Rect(bounds.Left / scale, bounds.Top / scale, bounds.Width / scale, bounds.Height / scale); + } + + /// + /// Gets the DPI scaling factor (relative to 96 DPI) of the screen containing the given bounds. + /// + private static double GetDpiScale(System.Drawing.Rectangle bounds) + { + try + { + POINT center = new POINT + { + X = bounds.Left + (bounds.Width / 2), + Y = bounds.Top + (bounds.Height / 2) + }; + IntPtr monitor = MonitorFromPoint(center, MonitorDefaultToNearest); + if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0) + { + return dpiX / 96.0; + } + } + catch (Exception) + { + // Fall through to no scaling if DPI APIs are unavailable. + } + + return 1.0; + } + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(POINT point, uint dwFlags); + + [DllImport("shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool EnumDisplayDevices(string? lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DISPLAY_DEVICE + { + public uint cb; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string DeviceName; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceString; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceID; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceKey; + + public uint StateFlags; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + private const long DpiAwarenessContextPerMonitorV2 = -4; + + private delegate bool EnumMonitorsProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData); + + [DllImport("user32.dll")] + private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); + + [DllImport("user32.dll")] + private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, EnumMonitorsProc lpfnEnum, IntPtr dwData); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORRECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFOEX + { + public uint cbSize; + + public MONITORRECT rcMonitor; + + public MONITORRECT rcWork; + + public uint dwFlags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } +} diff --git a/src/DesktopMagic/Helpers/SharePackage.cs b/src/DesktopMagic/Helpers/SharePackage.cs new file mode 100644 index 0000000..9f0782e --- /dev/null +++ b/src/DesktopMagic/Helpers/SharePackage.cs @@ -0,0 +1,327 @@ +using DesktopMagic.Plugins; +using DesktopMagic.Settings; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace DesktopMagic.Helpers; + +/// +/// Converts layouts and themes to and from the portable JSON content that is uploaded to mod.io. +/// +internal static class SharePackage +{ + public const int FormatVersion = 1; + public const string LayoutTag = "Layout"; + public const string ThemeTag = "Theme"; + public const string ContentFileName = "content.json"; + + private static readonly JsonSerializerOptions jsonOptions = new() + { + Converters = { new ColorJsonConverter() } + }; + + /// The content written to . + /// Hash of the shared content, used to detect local changes. + /// mod.io plugin IDs the layout uses. + /// Names of locally developed plugins the layout uses, which others cannot install. + public sealed record Package(JsonObject Json, string Hash, IReadOnlyList RequiredPlugins, IReadOnlyList LocalPlugins); + + public static string GetTag(IModIoShareable item) => item is Layout ? LayoutTag : ThemeTag; + + public static Package Build(IModIoShareable item) + { + return item switch + { + Layout layout => BuildLayout(layout), + Theme theme => BuildTheme(theme), + _ => throw new ArgumentException($"Unsupported item type {item.GetType().Name}", nameof(item)) + }; + } + + private static Package BuildTheme(Theme theme) + { + JsonObject root = new() + { + ["format"] = FormatVersion, + ["type"] = ThemeTag, + ["themes"] = new JsonArray(SerializeTheme(theme)), + }; + + return new Package(root, ComputeHash(root), [], []); + } + + private static Package BuildLayout(Layout layout) + { + DesktopMagicSettings settings = Manager.Instance.Settings; + IReadOnlyDictionary installedPlugins = Manager.Instance.Plugins; + + Layout copy = new(layout.Name) + { + ScreenAspectRatio = layout.ScreenAspectRatio, + }; + + List themeNames = [layout.Theme.Name]; + List requiredPlugins = []; + List localPlugins = []; + + // Only enabled widgets are shared. Disabled entries are just placeholders for every installed plugin. + foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled).OrderBy(kvp => kvp.Key)) + { + copy.Plugins[pluginId] = new PluginSettings + { + Enabled = true, + Position = pluginSettings.Position, + Size = pluginSettings.Size, + CurrentThemeName = pluginSettings.CurrentThemeName, + Settings = pluginSettings.Settings.Where(setting => setting.Shareable).ToList(), + }; + + if (pluginSettings.CurrentThemeName is not null && settings.Themes.Any(t => t.Name == pluginSettings.CurrentThemeName) && !themeNames.Contains(pluginSettings.CurrentThemeName)) + { + themeNames.Add(pluginSettings.CurrentThemeName); + } + + // Built-in plugins have no directory and are available everywhere. + if (installedPlugins.TryGetValue(pluginId, out InternalPluginData? pluginData) && !string.IsNullOrEmpty(pluginData.DirectoryPath)) + { + if (pluginData.Metadata.IsLocalPlugin) + { + localPlugins.Add(pluginData.Metadata.Name); + } + else + { + requiredPlugins.Add(pluginId); + } + } + } + + copy.CurrentThemeName = layout.Theme.Name; + + JsonObject layoutNode = JsonSerializer.SerializeToNode(copy, jsonOptions)!.AsObject(); + _ = layoutNode.Remove(nameof(Layout.ModIo)); + + JsonArray themesNode = []; + foreach (string themeName in themeNames) + { + Theme? theme = settings.Themes.FirstOrDefault(t => t.Name == themeName); + if (theme is not null) + { + themesNode.Add(SerializeTheme(theme)); + } + } + + JsonObject root = new() + { + ["format"] = FormatVersion, + ["type"] = LayoutTag, + ["layout"] = layoutNode, + ["themes"] = themesNode, + }; + + // Required plugins are derived from the layout and depend on what is currently loaded, so they are not part of the hash. + string hash = ComputeHash(root); + root["requiredPlugins"] = new JsonArray(requiredPlugins.Select(id => (JsonNode)id).ToArray()); + + return new Package(root, hash, requiredPlugins, localPlugins); + } + + public static JsonObject Parse(string json) + { + JsonObject root = JsonNode.Parse(json)?.AsObject() ?? throw new FormatException("The downloaded content is empty."); + + int format = root["format"]?.GetValue() ?? 0; + if (format is < 1 or > FormatVersion) + { + throw new FormatException($"Unsupported content format {format}. Please update DesktopMagic."); + } + + return root; + } + + public static IReadOnlyList GetRequiredPlugins(JsonObject root) + { + return root["requiredPlugins"]?.AsArray().Select(node => node!.GetValue()).ToList() ?? []; + } + + /// + /// Adds the downloaded content to the settings, or updates with it. + /// + public static IModIoShareable Apply(JsonObject root, IModIoShareable? existing) + { + DesktopMagicSettings settings = Manager.Instance.Settings; + string type = root["type"]?.GetValue() ?? string.Empty; + + List themes = root["themes"]?.AsArray() + .Select(node => node.Deserialize(jsonOptions)) + .OfType() + .ToList() ?? []; + + if (type == ThemeTag) + { + Theme incoming = themes.FirstOrDefault() ?? throw new FormatException("The downloaded theme is empty."); + incoming.ModIo = null; + + if (existing is Theme existingTheme) + { + CopyThemeValues(incoming, existingTheme); + return existingTheme; + } + + incoming.Name = GetUniqueName(incoming.Name, settings.Themes.Select(t => t.Name)); + settings.Themes.Add(incoming); + return incoming; + } + + if (type != LayoutTag) + { + throw new FormatException($"Unknown content type \"{type}\"."); + } + + JsonObject layoutNode = root["layout"]?.AsObject() ?? throw new FormatException("The downloaded layout is empty."); + Layout incomingLayout = layoutNode.Deserialize(jsonOptions) ?? throw new FormatException("The downloaded layout is invalid."); + string layoutName = existing?.Name ?? GetUniqueName(incomingLayout.Name, settings.Layouts.Select(l => l.Name)); + + Dictionary themeNameMap = []; + foreach (Theme theme in themes) + { + themeNameMap[theme.Name] = AddEmbeddedTheme(theme, layoutName); + } + + // Read the raw name, the getter falls back to another theme when the name does not exist yet. + string? layoutThemeName = layoutNode[nameof(Layout.CurrentThemeName)]?.GetValue(); + string? mappedLayoutThemeName = layoutThemeName is not null && themeNameMap.TryGetValue(layoutThemeName, out string? mapped) ? mapped : layoutThemeName; + + Layout target = existing as Layout ?? new Layout(layoutName); + + Dictionary plugins = []; + foreach ((uint pluginId, PluginSettings incomingPlugin) in incomingLayout.Plugins) + { + if (incomingPlugin.CurrentThemeName is not null && themeNameMap.TryGetValue(incomingPlugin.CurrentThemeName, out string? pluginThemeName)) + { + incomingPlugin.CurrentThemeName = pluginThemeName; + } + + // Keep values that are never shared (e.g. URLs or locations) and saved widget data from the local copy. + if (target.Plugins.TryGetValue(pluginId, out PluginSettings? localPlugin)) + { + incomingPlugin.State = localPlugin.State; + incomingPlugin.Settings = [.. incomingPlugin.Settings, .. localPlugin.Settings.Where(local => !incomingPlugin.Settings.Exists(s => s.Id == local.Id))]; + } + + incomingPlugin.Owner = target; + plugins[pluginId] = incomingPlugin; + } + + // Widgets the new version no longer uses are disabled, keeping their settings. + foreach ((uint pluginId, PluginSettings localPlugin) in target.Plugins.Where(kvp => !plugins.ContainsKey(kvp.Key))) + { + localPlugin.Enabled = false; + plugins[pluginId] = localPlugin; + } + + target.ScreenAspectRatio = incomingLayout.ScreenAspectRatio; + target.Plugins = plugins; + target.CurrentThemeName = mappedLayoutThemeName; + + if (existing is null) + { + settings.Layouts.Add(target); + } + + return target; + } + + /// + /// Adds a theme that is embedded in a layout and returns the local name it is available under. + /// + private static string AddEmbeddedTheme(Theme theme, string layoutName) + { + DesktopMagicSettings settings = Manager.Instance.Settings; + theme.ModIo = null; + + Theme? sameName = settings.Themes.FirstOrDefault(t => t.Name == theme.Name); + if (sameName is null) + { + settings.Themes.Add(theme); + return theme.Name; + } + + if (HasSameValues(sameName, theme)) + { + return sameName.Name; + } + + // A different theme with the same name exists (e.g. "Default"). Use a name tied to the layout, + // which later updates of the same layout overwrite instead of adding more copies. + string layoutThemeName = $"{theme.Name} ({layoutName})"; + Theme? layoutTheme = settings.Themes.FirstOrDefault(t => t.Name == layoutThemeName); + + if (layoutTheme is null) + { + theme.Name = layoutThemeName; + settings.Themes.Add(theme); + } + else + { + CopyThemeValues(theme, layoutTheme); + } + + return layoutThemeName; + } + + private static void CopyThemeValues(Theme source, Theme target) + { + target.PrimaryColor = source.PrimaryColor; + target.SecondaryColor = source.SecondaryColor; + target.BackgroundColor = source.BackgroundColor; + target.Font = source.Font; + target.CornerRadius = source.CornerRadius; + target.Margin = source.Margin; + } + + private static bool HasSameValues(Theme a, Theme b) + { + JsonObject nodeA = SerializeTheme(a); + JsonObject nodeB = SerializeTheme(b); + _ = nodeA.Remove(nameof(Theme.Name)); + _ = nodeB.Remove(nameof(Theme.Name)); + + return JsonNode.DeepEquals(nodeA, nodeB); + } + + private static JsonObject SerializeTheme(Theme theme) + { + JsonObject node = JsonSerializer.SerializeToNode(theme, jsonOptions)!.AsObject(); + _ = node.Remove(nameof(Theme.ModIo)); + return node; + } + + private static string GetUniqueName(string name, IEnumerable existingNames) + { + HashSet names = existingNames.ToHashSet(); + if (!names.Contains(name)) + { + return name; + } + + int index = 2; + while (names.Contains($"{name} ({index})")) + { + index++; + } + + return $"{name} ({index})"; + } + + private static string ComputeHash(JsonObject root) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(root.ToJsonString())); + return Convert.ToHexString(hash); + } +} diff --git a/src/DesktopMagic/Helpers/SharePreviewRenderer.cs b/src/DesktopMagic/Helpers/SharePreviewRenderer.cs new file mode 100644 index 0000000..884d5a9 --- /dev/null +++ b/src/DesktopMagic/Helpers/SharePreviewRenderer.cs @@ -0,0 +1,148 @@ +using DesktopMagic.Plugins; +using DesktopMagic.Settings; + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +using Point = System.Windows.Point; + +namespace DesktopMagic.Helpers; + +/// +/// Renders the logo image used for layouts and themes on mod.io (mod.io requires one, at least 512x288). +/// +internal static class SharePreviewRenderer +{ + private const int Width = 1280; + private const int Height = 720; + + private static readonly Color canvasColor = Color.FromRgb(0x1C, 0x1C, 0x24); + private static readonly Color screenColor = Color.FromRgb(0x2E, 0x2E, 0x3A); + + public static void Render(IModIoShareable item, string path) + { + DrawingVisual visual = new(); + + using (DrawingContext context = visual.RenderOpen()) + { + context.DrawRectangle(new SolidColorBrush(canvasColor), null, new Rect(0, 0, Width, Height)); + + switch (item) + { + case Layout layout: + DrawLayout(context, layout); + break; + + case Theme theme: + DrawTheme(context, theme); + break; + } + } + + RenderTargetBitmap bitmap = new(Width, Height, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + + using FileStream stream = File.Create(path); + encoder.Save(stream); + } + + private static void DrawLayout(DrawingContext context, Layout layout) + { + const double padding = 60; + double aspectRatio = layout.ScreenAspectRatio > 0 ? layout.ScreenAspectRatio : 16.0 / 9.0; + + double availableWidth = Width - (padding * 2); + double availableHeight = Height - (padding * 2); + double screenWidth = Math.Min(availableWidth, availableHeight * aspectRatio); + double screenHeight = screenWidth / aspectRatio; + + Rect screen = new((Width - screenWidth) / 2, (Height - screenHeight) / 2, screenWidth, screenHeight); + context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, screen, 12, 12); + + foreach ((uint pluginId, PluginSettings pluginSettings) in layout.Plugins.Where(kvp => kvp.Value.Enabled)) + { + Theme theme = pluginSettings.Theme; + + Rect widget = new( + screen.X + (pluginSettings.Position.X * screen.Width), + screen.Y + (pluginSettings.Position.Y * screen.Height), + Math.Max(4, pluginSettings.Size.X * screen.Width), + Math.Max(4, pluginSettings.Size.Y * screen.Height)); + widget.Intersect(screen); + + if (widget.IsEmpty) + { + continue; + } + + Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor); + if (background.A < 0x30) + { + background = Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF); + } + + Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor); + double radius = Math.Min(theme.CornerRadius * screen.Width / 1920, Math.Min(widget.Width, widget.Height) / 2); + + context.DrawRoundedRectangle(new SolidColorBrush(background), new Pen(new SolidColorBrush(primary), 2), widget, radius, radius); + + string name = Manager.Instance.Plugins.TryGetValue(pluginId, out InternalPluginData? pluginData) ? pluginData.Metadata.Name : pluginSettings.Metadata.Name; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + FormattedText text = CreateText(name, theme.Font, Math.Clamp(widget.Height / 4, 10, 28), primary); + text.MaxTextWidth = Math.Max(1, widget.Width - 12); + text.MaxTextHeight = Math.Max(1, widget.Height - 8); + text.Trimming = TextTrimming.CharacterEllipsis; + + context.DrawText(text, new Point(widget.X + 6, widget.Y + ((widget.Height - Math.Min(text.Height, text.MaxTextHeight)) / 2))); + } + } + + private static void DrawTheme(DrawingContext context, Theme theme) + { + Color background = MultiColorConverter.ConvertToMediaColor(theme.BackgroundColor); + Color primary = MultiColorConverter.ConvertToMediaColor(theme.PrimaryColor); + Color secondary = MultiColorConverter.ConvertToMediaColor(theme.SecondaryColor); + + Rect card = new(120, 100, Width - 240, Height - 200); + double radius = Math.Clamp(theme.CornerRadius * 2, 0, card.Height / 2); + + context.DrawRoundedRectangle(new SolidColorBrush(screenColor), null, card, radius, radius); + context.DrawRoundedRectangle(new SolidColorBrush(background), null, card, radius, radius); + + FormattedText title = CreateText(theme.Name, theme.Font, 96, primary); + title.MaxTextWidth = card.Width - 120; + title.MaxLineCount = 1; + title.Trimming = TextTrimming.CharacterEllipsis; + context.DrawText(title, new Point(card.X + 60, card.Y + 60)); + + FormattedText subtitle = CreateText(theme.Font, theme.Font, 48, secondary); + subtitle.MaxTextWidth = card.Width - 120; + subtitle.MaxLineCount = 1; + subtitle.Trimming = TextTrimming.CharacterEllipsis; + context.DrawText(subtitle, new Point(card.X + 60, card.Y + 60 + title.Height + 10)); + + Color[] swatches = [primary, secondary, background]; + for (int i = 0; i < swatches.Length; i++) + { + Point center = new(card.X + 110 + (i * 130), card.Bottom - 110); + context.DrawEllipse(new SolidColorBrush(swatches[i]), new Pen(new SolidColorBrush(Color.FromArgb(0x60, 0x80, 0x80, 0x80)), 3), center, 50, 50); + } + } + + private static FormattedText CreateText(string text, string font, double size, Color color) + { + return new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, new Typeface(font), size, new SolidColorBrush(color), 1.0); + } +} diff --git a/src/DesktopMagic/Helpers/Win32Wrapper.cs b/src/DesktopMagic/Helpers/Win32Wrapper.cs index 6c46996..d950990 100644 --- a/src/DesktopMagic/Helpers/Win32Wrapper.cs +++ b/src/DesktopMagic/Helpers/Win32Wrapper.cs @@ -10,6 +10,18 @@ internal class W32 public const int SM_CYSCREEN = 1; + public const int DWMWA_CLOAKED = 14; + + public const int GWL_EXSTYLE = -20; + + public const uint GW_HWNDNEXT = 2; + + public const uint GW_HWNDPREV = 3; + + public const int LWA_ALPHA = 0x2; + + public const long WS_EX_LAYERED = 0x00080000L; + public const int SPI_SETDESKWALLPAPER = 20; public const int SPIF_SENDWININICHANGE = 0x02; public const int SPIF_UPDATEINIFILE = 0x01; @@ -55,6 +67,26 @@ internal class W32 [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(nint hwnd, int attr, ref int attrValue, int attrSize); + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetWindowRect(nint hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + public static extern int GetWindowLong(nint hWnd, int nIndex); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetLayeredWindowAttributes(nint hWnd, out int colorKey, out byte alpha, out int dwFlags); + + [DllImport("user32.dll")] + public static extern nint GetWindow(nint hWnd, uint uCmd); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(nint hWnd, out uint processId); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam); diff --git a/src/DesktopMagic/MainWindow.xaml b/src/DesktopMagic/MainWindow.xaml index f15a638..c996629 100644 --- a/src/DesktopMagic/MainWindow.xaml +++ b/src/DesktopMagic/MainWindow.xaml @@ -22,7 +22,7 @@ MinHeight="600" MinWidth="940" ExtendsContentIntoTitleBar="True" - WindowState="Minimized" + WindowState="Normal" Loaded="Window_Loaded"> diff --git a/src/DesktopMagic/MainWindow.xaml.cs b/src/DesktopMagic/MainWindow.xaml.cs index 4672d65..2ca6e78 100644 --- a/src/DesktopMagic/MainWindow.xaml.cs +++ b/src/DesktopMagic/MainWindow.xaml.cs @@ -19,6 +19,7 @@ public partial class MainWindow : FluentWindow { private readonly Manager _manager = Manager.Instance; private readonly MainWindowDataContext _mainWindowDataContext = new(); + private OcclusionMonitor? occlusionMonitor; [Obsolete] public MainWindow() @@ -39,6 +40,8 @@ public partial class MainWindow : FluentWindow { App.Logger.LogInfo("Loading application", source: "MainWindow"); + Microsoft.Win32.SystemEvents.DisplaySettingsChanged += SystemEvents_DisplaySettingsChanged; + _mainWindowDataContext.IsLoading = true; // Load plugins and settings through manager @@ -47,6 +50,22 @@ public partial class MainWindow : FluentWindow _manager.LoadPlugins(); _manager.LoadLayout(); + // Pause plugin rendering while widgets are fully occluded by other windows. + occlusionMonitor = new OcclusionMonitor(); + occlusionMonitor.Start(); + + if (_manager.Settings.IsFirstRun) + { + _manager.Settings.IsFirstRun = false; + _ = Activate(); + } + else + { + WindowState = WindowState.Minimized; + Visibility = Visibility.Collapsed; + ShowInTaskbar = false; + } + _manager.IsLoaded = true; _mainWindowDataContext.IsLoading = false; await ShowLatestReleaseInfoAfterUpdateAsync(); @@ -87,6 +106,11 @@ public partial class MainWindow : FluentWindow private void Window_Closed(object sender, EventArgs e) { + Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= SystemEvents_DisplaySettingsChanged; + + occlusionMonitor?.Dispose(); + occlusionMonitor = null; + Visibility = Visibility.Collapsed; UpdateLayout(); _manager.CloseAllPluginWindows(); @@ -101,6 +125,16 @@ public partial class MainWindow : FluentWindow } } + private void SystemEvents_DisplaySettingsChanged(object? sender, EventArgs e) + { + // Re-enumerate screens and reload all widget windows when monitors are added or removed. + _ = Application.Current.Dispatcher.BeginInvoke(() => + { + _mainWindowDataContext.RefreshScreens(); + _manager.LoadLayout(); + }); + } + internal void RestoreWindow() { for (int i = 0; i < 10; i++) diff --git a/src/DesktopMagic/Manager.cs b/src/DesktopMagic/Manager.cs index c7bac72..ba049b1 100644 --- a/src/DesktopMagic/Manager.cs +++ b/src/DesktopMagic/Manager.cs @@ -5,6 +5,7 @@ using DesktopMagic.Settings; using System; using System.Collections.Generic; +using System.Drawing; using System.IO; using System.Linq; using System.Text.Json; @@ -35,6 +36,9 @@ public sealed class Manager } } + // Name of the built-in layout that shows no widgets on a screen. + public const string EmptyLayoutName = "Empty"; + // Plugin management private readonly Dictionary _plugins = []; private readonly Dictionary _builtInPlugins = new() @@ -46,6 +50,10 @@ public sealed class Manager {new((string)App.LanguageDictionary["weather"], 5) { Author = "Stone_Red" }, typeof(WeatherPlugin)}, {new((string)App.LanguageDictionary["nextMeetingCountdown"], 6) { Author = "Stone_Red" }, typeof(NextMeetingCountdownPlugin)}, {new((string)App.LanguageDictionary["agenda"], 7) { Author = "Stone_Red" }, typeof(AgendaPlugin)}, +#if DEBUG + {new((string)App.LanguageDictionary["benchmark"], 8) { Author = "Stone_Red" }, typeof(BenchmarkPlugin)}, + {new((string)App.LanguageDictionary["skiaBenchmark"], 9) { Author = "Stone_Red" }, typeof(SkiaBenchmarkPlugin)}, +#endif }; // Window management @@ -63,6 +71,13 @@ public sealed class Manager public DesktopMagicSettings Settings { get; set; } = new(); public bool IsLoaded { get; set; } = false; + // Screen selection (which screen is currently edited in the UI) + public string? SelectedScreenDeviceName { get; set; } + + public System.Windows.Forms.Screen SelectedScreen => ScreenUtilities.GetScreenByDeviceName(SelectedScreenDeviceName) ?? ScreenUtilities.GetPrimaryScreen(); + + public Layout SelectedLayout => GetLayoutForScreen(SelectedScreen); + private readonly JsonSerializerOptions _jsonSettingsOptions = new() { Converters = { new ColorJsonConverter() } @@ -135,20 +150,46 @@ public sealed class Manager App.Logger.LogInfo($"Loaded {_plugins.Count} plugins", source: "Manager"); } - public void LoadPlugin(uint pluginId, Action? onPluginLoaded = null) + /// + /// Loads (or unloads) the given plugin on every screen that currently uses the given layout. + /// This keeps screens sharing a layout in sync when plugins are enabled or disabled. + /// + public void LoadPlugin(uint pluginId, Layout layout, Action? onPluginLoaded = null) { if (!_plugins.TryGetValue(pluginId, out InternalPluginData? internalPluginData)) { return; } - if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings)) + if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings)) { pluginSettings = new PluginSettings(); - Settings.CurrentLayout.Plugins.Add(pluginId, pluginSettings); + layout.Plugins.Add(pluginId, pluginSettings); } - IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id); + pluginSettings.Metadata = internalPluginData.Metadata; + pluginSettings.Owner = layout; + + foreach (System.Windows.Forms.Screen screen in ScreenUtilities.GetAllScreens()) + { + if (GetLayoutForScreen(screen) == layout) + { + EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, onPluginLoaded); + } + } + + layout.UpdatePlugins(); + } + + /// + /// Creates or closes the plugin window for a single screen, based on the plugin settings. + /// + private void EnsurePluginWindow(System.Windows.Forms.Screen screen, Layout layout, InternalPluginData internalPluginData, PluginSettings pluginSettings, Action? onPluginLoaded) + { + string screenDeviceName = screen.DeviceName; + Rectangle screenBounds = screen.Bounds; + + IPluginWindow? existingWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == screenDeviceName); if (existingWindow is not null || !pluginSettings.Enabled) { @@ -174,21 +215,21 @@ public sealed class Manager if (_builtInPlugins.TryGetValue(internalPluginData.Metadata, out Type? pluginType)) { - window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings) + window = new PluginWindow((Api.Plugin)Activator.CreateInstance(pluginType)!, internalPluginData.Metadata, pluginSettings, screenBounds, screenDeviceName) { Title = internalPluginData.Metadata.Id.ToString() }; } else if (internalPluginData.Type == PluginType.Web) { - window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath) + window = new WebPluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName) { Title = internalPluginData.Metadata.Id.ToString() }; } else { - window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath) + window = new PluginWindow(internalPluginData.Metadata, pluginSettings, internalPluginData.DirectoryPath, screenBounds, screenDeviceName) { Title = internalPluginData.Metadata.Id.ToString() }; @@ -203,10 +244,21 @@ public sealed class Manager Action exitHandler = () => { - PluginWindows.Remove(window); - BlockWindowsClosing = false; - window.Close(); - BlockWindowsClosing = true; + // Close the widget on every screen using this layout + foreach (System.Windows.Forms.Screen sharedScreen in ScreenUtilities.GetAllScreens()) + { + if (GetLayoutForScreen(sharedScreen) == layout) + { + IPluginWindow? sharedWindow = PluginWindows.FirstOrDefault(w => w.PluginMetadata.Id == internalPluginData.Metadata.Id && w.ScreenDeviceName == sharedScreen.DeviceName); + if (sharedWindow is not null) + { + PluginWindows.Remove(sharedWindow); + BlockWindowsClosing = false; + sharedWindow.Close(); + BlockWindowsClosing = true; + } + } + } pluginSettings.Enabled = false; }; @@ -224,7 +276,7 @@ public sealed class Manager App.Logger.LogInfo("Reloading plugins", source: "PluginManager"); LoadPlugins(); - LoadLayout(false); + LoadLayout(); } public void SetEditMode(bool editMode) @@ -241,6 +293,32 @@ public sealed class Manager SaveSettings(); } + private readonly Dictionary<(Layout, uint), SettingSynchronizer> settingSynchronizers = []; + + /// + /// Gets (or creates) the synchronizer that keeps the settings of all windows showing the + /// given plugin in the given layout in sync. + /// + internal SettingSynchronizer GetSettingSynchronizer(Layout layout, uint pluginId) + { + (Layout, uint) key = (layout, pluginId); + if (!settingSynchronizers.TryGetValue(key, out SettingSynchronizer? synchronizer)) + { + synchronizer = new SettingSynchronizer(); + settingSynchronizers.Add(key, synchronizer); + } + + return synchronizer; + } + + /// + /// Drops the synchronizer for the given plugin in the given layout once no windows use it anymore. + /// + internal void ReleaseSettingSynchronizer(Layout layout, uint pluginId) + { + _ = settingSynchronizers.Remove((layout, pluginId)); + } + #endregion #region Settings Management @@ -253,7 +331,9 @@ public sealed class Manager { Settings = new DesktopMagicSettings(); Settings.Layouts.Add(new Layout("Default")); + Settings.Layouts.Add(new Layout(EmptyLayoutName)); Settings.Themes.Add(new Theme("Default")); + Settings.SchemaVersion = 1; return; } @@ -270,9 +350,62 @@ public sealed class Manager Settings.Themes.Add(new Theme("Default")); } + if (Settings.SchemaVersion < 1) + { + MigrateToScreenAwareSettings(); + Settings.SchemaVersion = 1; + } + + if (!Settings.Layouts.Any(layout => layout.Name == EmptyLayoutName)) + { + Settings.Layouts.Add(new Layout(EmptyLayoutName)); + } + SettingsChanged?.Invoke(); } + /// + /// Migrates legacy settings to screen-aware layouts: + /// records the primary screen aspect ratio on each layout and converts + /// absolute pixel positions/sizes to percentages of the primary screen bounds. + /// + private void MigrateToScreenAwareSettings() + { + App.Logger.LogInfo("Migrating settings to screen-aware layouts", source: "Manager"); + + System.Windows.Forms.Screen primaryScreen = ScreenUtilities.GetPrimaryScreen(); + double aspectRatio = ScreenUtilities.GetAspectRatio(primaryScreen); + Rectangle bounds = primaryScreen.Bounds; + + foreach (Layout layout in Settings.Layouts) + { + if (layout.ScreenAspectRatio <= 0) + { + layout.ScreenAspectRatio = aspectRatio; + } + + foreach (PluginSettings plugin in layout.Plugins.Values) + { + if (plugin.Position.X > 1 || plugin.Position.Y > 1 || plugin.Position.X < 0 || plugin.Position.Y < 0) + { + plugin.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(plugin.Position.X, plugin.Position.Y), bounds); + } + + if (plugin.Size.X > 1 || plugin.Size.Y > 1) + { + plugin.Size = ScreenUtilities.SizeToPercent(new System.Windows.Point(plugin.Size.X, plugin.Size.Y), bounds); + } + } + } + + if (Settings.ScreenLayouts.Count == 0) + { + Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(primaryScreen)] = Settings.CurrentLayoutName ?? "Default"; + } + + App.Logger.LogInfo("Settings migrated to screen-aware layouts", source: "Manager"); + } + public void SaveSettings() { if (!IsLoaded) @@ -290,9 +423,81 @@ public sealed class Manager #region Layout Management - public void LoadLayout(bool minimize = true, Action? onComplete = null) + /// + /// Resolves the layout that should be applied to the given screen: + /// 1. the layout explicitly bound to this screen's device name, + /// 2. the layout with the closest matching aspect ratio, + /// 3. the first layout. + /// + public Layout GetLayoutForScreen(System.Windows.Forms.Screen screen) { - App.Logger.LogInfo("Loading layout", source: "Manager"); + if (Settings.ScreenLayouts.TryGetValue(ScreenUtilities.GetMonitorHardwareId(screen), out string? layoutName)) + { + Layout? bound = Settings.Layouts.FirstOrDefault(layout => layout.Name == layoutName); + if (bound is not null) + { + return bound; + } + } + + double targetRatio = ScreenUtilities.GetAspectRatio(screen); + Layout? byAspectRatio = Settings.Layouts + .Where(layout => layout.ScreenAspectRatio > 0) + .OrderBy(layout => Math.Abs(layout.ScreenAspectRatio - targetRatio)) + .FirstOrDefault(); + + if (byAspectRatio is not null) + { + return byAspectRatio; + } + + return Settings.Layouts.FirstOrDefault() ?? new Layout("ERROR"); + } + + /// + /// Binds the given layout to the given screen on this machine. + /// + public void BindLayoutToScreen(System.Windows.Forms.Screen screen, Layout layout) + { + Settings.ScreenLayouts[ScreenUtilities.GetMonitorHardwareId(screen)] = layout.Name; + SaveSettings(); + } + + /// + /// Deletes the layout, drops the screen bindings pointing to it and reloads the screens. + /// + public void RemoveLayout(Layout layout) + { + _ = Settings.Layouts.Remove(layout); + + List boundScreens = Settings.ScreenLayouts + .Where(kvp => kvp.Value == layout.Name) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (string screenId in boundScreens) + { + _ = Settings.ScreenLayouts.Remove(screenId); + } + + SaveSettings(); + LoadLayout(); + } + + /// + /// Gets all plugin windows currently shown on the given screen. + /// + public IEnumerable GetWindowsForScreen(string screenDeviceName) + { + return PluginWindows.Where(window => window.ScreenDeviceName == screenDeviceName).ToList(); + } + + /// + /// Loads all screens' layouts at once, opening the enabled widgets of every screen. + /// + public void LoadLayout(Action? onComplete = null) + { + App.Logger.LogInfo("Loading layouts", source: "Manager"); BlockWindowsClosing = false; foreach (IPluginWindow window in PluginWindows) @@ -303,7 +508,47 @@ public sealed class Manager BlockWindowsClosing = true; PluginWindows.Clear(); - bool showWindow = true; + foreach (System.Windows.Forms.Screen screen in ScreenUtilities.GetAllScreens()) + { + Layout layout = GetLayoutForScreen(screen); + LoadScreen(screen, layout); + } + + onComplete?.Invoke(); + App.Logger.LogInfo("Layouts loaded", source: "Manager"); + } + + /// + /// Reloads the widgets of a single screen using the layout currently bound to it. + /// + public void ReloadScreen(System.Windows.Forms.Screen screen) + { + App.Logger.LogInfo($"Reloading screen {screen.DeviceName}", source: "Manager"); + + List windows = PluginWindows.Where(window => window.ScreenDeviceName == screen.DeviceName).ToList(); + + BlockWindowsClosing = false; + foreach (IPluginWindow window in windows) + { + window.Close(); + } + + BlockWindowsClosing = true; + PluginWindows.RemoveAll(window => windows.Contains(window)); + + Layout layout = GetLayoutForScreen(screen); + LoadScreen(screen, layout); + } + + private void LoadScreen(System.Windows.Forms.Screen screen, Layout layout) + { + App.Logger.LogInfo($"Loading layout \"{layout.Name}\" for screen {screen.DeviceName}", source: "Manager"); + + // The empty layout intentionally shows no widgets and is never populated. + if (layout.Name == EmptyLayoutName) + { + return; + } // Load plugins foreach (uint pluginId in _plugins.Keys) @@ -311,42 +556,29 @@ public sealed class Manager InternalPluginData internalPluginData = _plugins[pluginId]; // Add plugin to layout if it doesn't exist - if (!Settings.CurrentLayout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings)) + if (!layout.Plugins.TryGetValue(pluginId, out PluginSettings? pluginSettings)) { - Settings.CurrentLayout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata }); + layout.Plugins.Add(pluginId, new PluginSettings() { Metadata = internalPluginData.Metadata, Owner = layout }); continue; } pluginSettings.Metadata = internalPluginData.Metadata; + pluginSettings.Owner = layout; if (pluginSettings.Enabled) { - LoadPlugin(pluginId); - } - - if (showWindow && pluginSettings.Enabled) - { - showWindow = false; + EnsurePluginWindow(screen, layout, internalPluginData, pluginSettings, null); } } // Remove plugins that are not loaded anymore - List pluginIdsToRemove = Settings.CurrentLayout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList(); + List pluginIdsToRemove = layout.Plugins.Keys.Where(id => !_plugins.ContainsKey(id)).ToList(); foreach (uint pluginId in pluginIdsToRemove) { - Settings.CurrentLayout.Plugins.Remove(pluginId); + layout.Plugins.Remove(pluginId); } - Settings.CurrentLayout.UpdatePlugins(); - - if (minimize && !showWindow) - { - Application.Current.MainWindow.WindowState = WindowState.Minimized; - Application.Current.MainWindow.ShowInTaskbar = false; - } - - onComplete?.Invoke(); - App.Logger.LogInfo("Layout loaded", source: "Manager"); + layout.UpdatePlugins(); } #endregion diff --git a/src/DesktopMagic/Pages/MainPage.xaml b/src/DesktopMagic/Pages/MainPage.xaml index e477d81..ac9dd0a 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml +++ b/src/DesktopMagic/Pages/MainPage.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:DesktopMagic.Pages" @@ -12,76 +13,119 @@ ScrollViewer.CanContentScroll="False" Title="MainPage"> - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Pages/MainPage.xaml.cs b/src/DesktopMagic/Pages/MainPage.xaml.cs index 008bf1b..7cf5450 100644 --- a/src/DesktopMagic/Pages/MainPage.xaml.cs +++ b/src/DesktopMagic/Pages/MainPage.xaml.cs @@ -20,7 +20,6 @@ public partial class MainPage : Page { private readonly Manager _manager = Manager.Instance; private readonly MainWindowDataContext _dataContext; - private bool _isLoadingLayout = false; public MainPage() { @@ -36,6 +35,7 @@ public partial class MainPage : Page // Subscribe to manager events _manager.PluginsChanged += OnPluginsChanged; _manager.EditModeChanged += OnEditModeChanged; + _manager.SettingsChanged += OnSettingsChanged; Loaded += MainPage_Loaded; Unloaded += MainPage_Unloaded; @@ -45,6 +45,9 @@ public partial class MainPage : Page { // Initialize edit checkbox state editCheckBox.IsChecked = _manager.IsEditMode; + + _dataContext.RefreshScreens(); + RefreshShareState(); } private void MainPage_Unloaded(object sender, RoutedEventArgs e) @@ -52,6 +55,7 @@ public partial class MainPage : Page // Unsubscribe from events _manager.PluginsChanged -= OnPluginsChanged; _manager.EditModeChanged -= OnEditModeChanged; + _manager.SettingsChanged -= OnSettingsChanged; } private void OnPluginsChanged() @@ -60,9 +64,20 @@ public partial class MainPage : Page { _dataContext.Settings = _manager.Settings; ApplyPluginsFilter(); + RefreshShareState(); }); } + private void OnSettingsChanged() + { + Dispatcher.Invoke(RefreshShareState); + } + + private void RefreshShareState() + { + ModIoService.RefreshShareState(_manager.SelectedLayout); + } + private void OnEditModeChanged(bool editMode) { Dispatcher.Invoke(() => @@ -76,6 +91,16 @@ public partial class MainPage : Page _manager.SetEditMode(editCheckBox.IsChecked == true); } + private void ScreenSelectorButton_Click(object sender, RoutedEventArgs e) + { + ScreenSelectorDialog dialog = new(_dataContext) + { + Owner = Window.GetWindow(this) + }; + + _ = dialog.ShowDialog(); + } + private void PluginCheckBox_Click(object sender, RoutedEventArgs e) { if (sender is not Control checkBox) @@ -85,7 +110,7 @@ public partial class MainPage : Page uint pluginId = uint.Parse(checkBox.Tag.ToString()!); - _manager.LoadPlugin(pluginId, (internalPluginData) => + _manager.LoadPlugin(pluginId, _manager.SelectedLayout, (internalPluginData) => { Dispatcher.Invoke(() => { @@ -145,32 +170,61 @@ public partial class MainPage : Page private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { - // Prevent recursive calls and only process if fully loaded - if (_isLoadingLayout || !_manager.IsLoaded || !IsLoaded) + ApplySelectedLayout(); + RefreshShareState(); + } + + private async void ShareLayoutButton_Click(object sender, RoutedEventArgs e) + { + shareLayoutButton.IsEnabled = false; + + try + { + await ModIoService.ShareAsync(_manager.SelectedLayout, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy); + } + finally + { + shareLayoutButton.IsEnabled = true; + } + } + + /// + /// Binds the currently selected layout to the currently selected screen and reloads it. + /// No-op when the layout is already the one bound to the screen (e.g. programmatic resets). + /// + private void ApplySelectedLayout() + { + if (_dataContext.SelectedScreenId is null) { 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) + System.Windows.Forms.Screen? screen = ScreenUtilities.GetScreenByDeviceName(_dataContext.SelectedScreenId); + if (screen is null) { - if (e.RemovedItems[0] == e.AddedItems[0]) - { - return; - } + return; } - try + string? layoutName = _dataContext.SelectedLayoutName; + if (layoutName is null) { - _isLoadingLayout = true; - _manager.SaveSettings(); - _manager.LoadLayout(false); + return; } - finally + + Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == layoutName); + if (layout is null) { - _isLoadingLayout = false; + return; } + + if (_manager.GetLayoutForScreen(screen) == layout) + { + return; + } + + _manager.BindLayoutToScreen(screen, layout); + _manager.ReloadScreen(screen); + _dataContext.RefreshSelection(); } private async void NewLayoutButton_Click(object sender, RoutedEventArgs e) @@ -194,18 +248,11 @@ public partial class MainPage : Page 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; - } + _manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim())); + _manager.SaveSettings(); + + // Select the new layout so the user can apply it to the current screen + _dataContext.SelectedLayoutName = inputDialog.ResponseText.Trim(); } } @@ -237,17 +284,29 @@ public partial class MainPage : Page return; } - try + Layout? layout = _manager.Settings.Layouts.FirstOrDefault(l => l.Name == _dataContext.SelectedLayoutName); + if (layout is null) { - _isLoadingLayout = true; - _ = _manager.Settings.Layouts.Remove(_manager.Settings.CurrentLayout); - _manager.SaveSettings(); - _manager.LoadLayout(false); + return; } - finally + + if (layout.Name == Manager.EmptyLayoutName) { - _isLoadingLayout = false; + Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("cannotDeleteEmptyLayout"), + CloseButtonText = "Ok" + }; + _ = await cannotDeleteMessageBox.ShowDialogAsync(); + return; } + + // A subscribed layout would be downloaded again on the next sync. + await ModIoService.UnsubscribeAsync(layout); + + _manager.RemoveLayout(layout); + _dataContext.RefreshSelection(); } #endregion diff --git a/src/DesktopMagic/Pages/ThemePage.xaml b/src/DesktopMagic/Pages/ThemePage.xaml index 2efdfba..9bbe023 100644 --- a/src/DesktopMagic/Pages/ThemePage.xaml +++ b/src/DesktopMagic/Pages/ThemePage.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:busyIndicator="https://github.com/moh3ngolshani/BusyIndicator" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:DesktopMagic.Pages" @@ -9,44 +10,66 @@ d:DesignHeight="450" d:DesignWidth="800" Title="ThemePage"> - - - - - + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + - - - - - + + + + + + diff --git a/src/DesktopMagic/Pages/ThemePage.xaml.cs b/src/DesktopMagic/Pages/ThemePage.xaml.cs index 1bfa67d..410097f 100644 --- a/src/DesktopMagic/Pages/ThemePage.xaml.cs +++ b/src/DesktopMagic/Pages/ThemePage.xaml.cs @@ -1,5 +1,6 @@ using DesktopMagic.DataContexts; using DesktopMagic.Dialogs; +using DesktopMagic.Helpers; using DesktopMagic.Plugins; using System.Linq; @@ -30,9 +31,15 @@ public partial class ThemePage : Page // Subscribe to manager events _manager.SettingsChanged += OnSettingsChanged; + Loaded += ThemePage_Loaded; Unloaded += ThemePage_Unloaded; } + private void ThemePage_Loaded(object sender, RoutedEventArgs e) + { + RefreshShareStates(); + } + private void ThemePage_Unloaded(object sender, RoutedEventArgs e) { // Unsubscribe from events @@ -44,9 +51,37 @@ public partial class ThemePage : Page Dispatcher.Invoke(() => { _dataContext.Settings = _manager.Settings; + RefreshShareStates(); }); } + private void RefreshShareStates() + { + foreach (Theme theme in _manager.Settings.Themes) + { + ModIoService.RefreshShareState(theme); + } + } + + private async void ShareThemeButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme) + { + return; + } + + button.IsEnabled = false; + + try + { + await ModIoService.ShareAsync(theme, Window.GetWindow(this), isBusy => _dataContext.IsSharing = isBusy); + } + finally + { + button.IsEnabled = true; + } + } + private async void AddThemeButton_Click(object sender, RoutedEventArgs e) { InputDialog inputDialog = new((string)FindResource("enterThemeName")) @@ -69,7 +104,7 @@ public partial class ThemePage : Page } _manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim())); - _manager.Settings.CurrentLayout.CurrentThemeName = inputDialog.ResponseText.Trim(); + _manager.SelectedLayout.CurrentThemeName = inputDialog.ResponseText.Trim(); _manager.SaveSettings(); } } @@ -104,6 +139,9 @@ public partial class ThemePage : Page if (themesListBox.SelectedItem is Theme theme) { + // A subscribed theme would be downloaded again on the next sync. + await ModIoService.UnsubscribeAsync(theme); + _ = _manager.Settings.Themes.Remove(theme); _manager.SaveSettings(); } @@ -122,5 +160,6 @@ public partial class ThemePage : Page }; _ = themeDialog.ShowDialog(); + ModIoService.RefreshShareState(theme); } } diff --git a/src/DesktopMagic/Plugins/IPluginWindow.cs b/src/DesktopMagic/Plugins/IPluginWindow.cs index d5e8ec4..6b05dc5 100644 --- a/src/DesktopMagic/Plugins/IPluginWindow.cs +++ b/src/DesktopMagic/Plugins/IPluginWindow.cs @@ -13,6 +13,7 @@ public interface IPluginWindow PluginMetadata PluginMetadata { get; } string PluginFolderPath { get; } string Title { get; set; } + string ScreenDeviceName { get; } void Exit(); void SetEditMode(bool enabled); @@ -20,5 +21,13 @@ public interface IPluginWindow void Hide(); void Close(); + void ApplySettingValue(string id, string value); + void ApplyButtonClick(string id); + + /// + /// Pauses or resumes rendering of the plugin window (e.g. when it is fully occluded by other windows). + /// + void SetRenderPaused(bool paused); + event System.ComponentModel.CancelEventHandler Closing; } diff --git a/src/DesktopMagic/Plugins/PluginData.cs b/src/DesktopMagic/Plugins/PluginData.cs index 92fd242..e0caa1e 100644 --- a/src/DesktopMagic/Plugins/PluginData.cs +++ b/src/DesktopMagic/Plugins/PluginData.cs @@ -3,6 +3,7 @@ using DesktopMagic.Api; using DesktopMagic.Settings; +using System; using System.Drawing; using Wpf.Ui.Controls; @@ -12,11 +13,23 @@ namespace DesktopMagic.Plugins; internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData { private readonly PluginWindow window = window; + private Size windowPixelSize = new(1, 1); public ITheme Theme => pluginSettings.Theme; public Size WindowSize => new Size((int)window.ActualWidth, (int)window.ActualHeight); + public Size WindowPixelSize => windowPixelSize; + + /// + /// Refreshes the cached device-pixel window size. Must be called from the UI thread + /// so the value can be read safely by plugins on their render thread. + /// + internal void UpdateWindowPixelSize(int width, int height) + { + windowPixelSize = new Size(Math.Max(1, width), Math.Max(1, height)); + } + public Point WindowPosition => new Point((int)window.Left, (int)window.Top); public string PluginName => window.PluginMetadata.Name; diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml b/src/DesktopMagic/Plugins/PluginManager.xaml index d27c0fb..b29c32a 100644 --- a/src/DesktopMagic/Plugins/PluginManager.xaml +++ b/src/DesktopMagic/Plugins/PluginManager.xaml @@ -31,19 +31,26 @@ + + - - + + + + + + + - + @@ -56,7 +63,7 @@ - + @@ -69,7 +76,7 @@ - + @@ -82,7 +89,7 @@ - + diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml.cs b/src/DesktopMagic/Plugins/PluginManager.xaml.cs index eedca7c..3779b7b 100644 --- a/src/DesktopMagic/Plugins/PluginManager.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginManager.xaml.cs @@ -1,6 +1,7 @@ using DesktopMagic.DataContexts; using DesktopMagic.Dialogs; using DesktopMagic.Helpers; +using DesktopMagic.Settings; using Modio; using Modio.Filters; @@ -32,40 +33,31 @@ namespace DesktopMagic.Plugins; /// public partial class PluginManager : Page { - private const int ModIoGameId = 5665; - private const string ModIoApiKey = "88e6ea774c3a502b06114e7fee0829ac"; + private const uint ModIoGameId = ModIoService.GameId; private readonly HttpClient httpClient = new(); 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() { Interval = TimeSpan.FromMilliseconds(300), }; + private bool changed = false; private Client modIoClient; + // mod.io data of the local layouts and themes linked to a mod, by mod ID. + private readonly Dictionary linkedMods = []; + public PluginManager() { Resources.MergedDictionaries.Add(App.LanguageDictionary); - string? modIoAccessToken = MainWindowDataContext.GetSettings().ModIoAccessToken; - - 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"); - } + modIoClient = ModIoService.CreateClient(); + pluginManagerDataContext.IsAuthenticated = ModIoService.IsAuthenticated; + App.Logger.LogInfo($"Initialized mod.io client {(ModIoService.IsAuthenticated ? "with" : "without")} authentication", source: "PluginManager"); InitializeComponent(); @@ -73,13 +65,85 @@ public partial class PluginManager : Page searchTimer.Tick += async (sender, e) => { searchTimer.Stop(); - await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText); + + if (pluginManagerDataContext.Category == ModCategory.Plugins) + { + await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText); + } + else + { + await SearchShared(pluginManagerDataContext.AllPluginsSearchText); + } }; Loaded += PluginManager_Loaded; Unloaded += PluginManager_Unloaded; } + 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); + + if (Directory.Exists(pluginPath)) + { + try + { + Directory.Delete(pluginPath, true); + App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager"); + } + catch (Exception ex) + { + 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"); + } + } + + if (pluginEntryDataContext is not null) + { + _ = pluginManagerDataContext.InstalledPlugins.Remove(pluginEntryDataContext); + App.Logger.LogInfo($"Removed plugin {id} from installed plugins list", source: "PluginManager"); + } + + if (pluginManagerDataContext.IsAuthenticated) + { + 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; + } + + [GeneratedRegex(@"[^a-zA-Z0-9]")] + private static partial Regex IdentifierNameRegex(); + + 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 async void PluginManager_Loaded(object sender, RoutedEventArgs e) { await InitializePluginManager(); @@ -159,7 +223,7 @@ public partial class PluginManager : Page IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); await foreach (Mod mod in mods) { - if (pluginIds.Contains(mod.Id)) + if (pluginIds.Contains(mod.Id) || !ModIoService.IsPluginMod(mod)) { continue; } @@ -174,64 +238,13 @@ public partial class PluginManager : Page } await SyncPlugins(); + await UpdateLinkedItems(); + await PopulateShared(); 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); - - if (Directory.Exists(pluginPath)) - { - try - { - Directory.Delete(pluginPath, true); - App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager"); - } - catch (Exception ex) - { - 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"); - } - } - - if (pluginEntryDataContext is not null) - { - _ = pluginManagerDataContext.InstalledPlugins.Remove(pluginEntryDataContext); - App.Logger.LogInfo($"Removed plugin {id} from installed plugins list", source: "PluginManager"); - } - - if (pluginManagerDataContext.IsAuthenticated) - { - 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; - } - - [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"); @@ -343,20 +356,177 @@ public partial class PluginManager : Page private void InstalledPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) { - if (string.IsNullOrWhiteSpace(pluginManagerDataContext.InstalledPluginsSearchText)) + FilterInstalled(); + } + + private void FilterInstalled() + { + string searchText = pluginManagerDataContext.InstalledPluginsSearchText; + + foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.ShownInstalled) { - foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins) + pluginEntryDataContext.IsVisible = string.IsNullOrWhiteSpace(searchText) || pluginEntryDataContext.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase); + } + } + + private async void CategoryComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) + { + return; + } + + pluginManagerDataContext.CategoryIndex = categoryComboBox.SelectedIndex; + pluginManagerDataContext.IsLoading = true; + await PopulateShared(); + FilterInstalled(); + pluginManagerDataContext.IsLoading = false; + } + + /// + /// Fetches the mods of all linked layouts and themes and applies newer versions. + /// Subscribed items are also updated by the subscription sync, this covers items installed while logged out. + /// + private async Task UpdateLinkedItems() + { + linkedMods.Clear(); + + foreach (IModIoShareable item in ModIoService.GetSharedItems().Where(item => item.ModIo is not null).ToList()) + { + uint modId = item.ModIo!.ModId; + + try { - pluginEntryDataContext.IsVisible = true; + Mod mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get(); + linkedMods[modId] = mod; + + if (await ModIoService.UpdateAsync(item, mod, EnsurePluginInstalled)) + { + changed = true; + } + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to check \"{item.Name}\" (mod {modId}) for updates: {ex.Message}", source: "PluginManager"); } } - else + } + + /// + /// Fills the lists of the selected layout or theme category. + /// + private async Task PopulateShared() + { + pluginManagerDataContext.InstalledShared.Clear(); + pluginManagerDataContext.AllShared.Clear(); + + if (pluginManagerDataContext.Category == ModCategory.Plugins) { - foreach (PluginEntryDataContext pluginEntryDataContext in pluginManagerDataContext.InstalledPlugins) + return; + } + + IEnumerable items = pluginManagerDataContext.Category == ModCategory.Layouts + ? _manager.Settings.Layouts + : _manager.Settings.Themes; + + foreach (IModIoShareable item in items.Where(item => item.ModIo is not null).ToList()) + { + uint modId = item.ModIo!.ModId; + + if (!linkedMods.TryGetValue(modId, out Mod? mod)) { - pluginEntryDataContext.IsVisible = pluginEntryDataContext.Name.Contains(pluginManagerDataContext.InstalledPluginsSearchText, StringComparison.OrdinalIgnoreCase); + try + { + mod = await modIoClient.Games[ModIoGameId].Mods[modId].Get(); + linkedMods[modId] = mod; + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to fetch mod {modId} of \"{item.Name}\": {ex.Message}", source: "PluginManager"); + } + } + + // Show the local name, it can differ from the mod name (e.g. "Default (2)"). + PluginMetadata metadata = mod is null ? new PluginMetadata(item.Name, modId) : new PluginMetadata(mod) { Name = item.Name }; + pluginManagerDataContext.InstalledShared.Add(new PluginEntryDataContext(metadata, new CommandHandler(async () => await RemoveShared(item)), PluginEntryDataContext.Mode.Uninstall)); + } + + await SearchShared(pluginManagerDataContext.AllPluginsSearchText); + } + + private async Task SearchShared(string searchString) + { + pluginManagerDataContext.AllShared.Clear(); + + if (pluginManagerDataContext.Category == ModCategory.Plugins) + { + pluginManagerDataContext.IsSearching = false; + return; + } + + string tag = pluginManagerDataContext.Category == ModCategory.Layouts ? SharePackage.LayoutTag : SharePackage.ThemeTag; + Filter filter = ModFilter.Tags.Eq(tag); + + if (!string.IsNullOrWhiteSpace(searchString)) + { + if (!searchString.Contains('*')) + { + searchString = $"*{searchString.Trim()}*"; + } + + filter = filter.And(ModFilter.Name.Like(searchString)); + } + + filter = filter.And(ModFilter.Popular.Desc()).Limit(100); + + try + { + App.Logger.LogInfo($"Fetching shared {tag} mods from mod.io", source: "PluginManager"); + IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); + await foreach (Mod mod in mods) + { + if (ModIoService.FindLinkedItem(mod.Id) is not null) + { + continue; + } + + pluginManagerDataContext.AllShared.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await InstallShared(mod)), PluginEntryDataContext.Mode.Install)); } } + catch (Exception ex) + { + App.Logger.LogError($"Failed to fetch shared {tag} mods from mod.io: {ex.Message}", source: "PluginManager"); + } + + pluginManagerDataContext.IsSearching = false; + } + + private async Task InstallShared(Mod mod) + { + pluginManagerDataContext.IsLoading = true; + + if (await ModIoService.InstallAsync(mod, EnsurePluginInstalled)) + { + linkedMods[mod.Id] = mod; + changed = true; + } + + await PopulateShared(); + FilterInstalled(); + pluginManagerDataContext.IsLoading = false; + } + + private async Task RemoveShared(IModIoShareable item) + { + if (!await ModIoService.RemoveAsync(item)) + { + return; + } + + pluginManagerDataContext.IsLoading = true; + await PopulateShared(); + FilterInstalled(); + pluginManagerDataContext.IsLoading = false; } private async Task SearchAllPlugins(string searchString) @@ -376,7 +546,7 @@ public partial class PluginManager : Page IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); await foreach (Mod mod in mods) { - if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id)) + if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id) || !ModIoService.IsPluginMod(mod)) { continue; } @@ -664,52 +834,21 @@ public class {pluginSafeName}Plugin : Plugin { if (pluginManagerDataContext.IsAuthenticated) { - App.Logger.LogInfo("Logging out from mod.io", source: "PluginManager"); - MainWindowDataContext.GetSettings().ModIoAccessToken = null; + ModIoService.LogOut(); + modIoClient = ModIoService.CreateClient(); pluginManagerDataContext.IsAuthenticated = false; return; } - App.Logger.LogInfo("Starting mod.io authentication", source: "PluginManager"); - try { - InputDialog inputDialog = new((string)FindResource("enterModIoEmail"), "Plugin Manager") + if (!await ModIoService.LogInAsync(Window.GetWindow(this), "Plugin Manager")) { - 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 = Window.GetWindow(this), - }; - - if (inputDialog.ShowDialog() != true) - { - App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager"); return; } pluginManagerDataContext.IsLoading = true; - - AccessToken accessToken = await modIoClient.Auth.SecurityCode(ModIoApiKey, inputDialog.ResponseText); - - 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; + modIoClient = ModIoService.CreateClient(); pluginManagerDataContext.IsAuthenticated = true; App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager"); @@ -729,7 +868,9 @@ public class {pluginSafeName}Plugin : Plugin } } + await ModIoService.SubscribeToLinkedItemsAsync(); await SyncPlugins(); + await PopulateShared(); } catch (Exception ex) { @@ -757,7 +898,8 @@ public class {pluginSafeName}Plugin : Plugin try { - IReadOnlyList mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList(); + IReadOnlyList subscriptions = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList(); + List mods = subscriptions.Where(ModIoService.IsPluginMod).ToList(); App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager"); List pluginsToRemove = []; @@ -790,6 +932,12 @@ public class {pluginSafeName}Plugin : Plugin } App.Logger.LogInfo("Plugin sync completed", source: "PluginManager"); + + // Shared layouts and themes are synced after the plugins, so the plugins they use are installed first. + if (await ModIoService.SyncSubscriptionsAsync(subscriptions, EnsurePluginInstalled)) + { + changed = true; + } } catch (Exception ex) { @@ -797,15 +945,17 @@ public class {pluginSafeName}Plugin : Plugin } } - private static string GetPluginSafeName(string pluginName) + private async Task EnsurePluginInstalled(uint pluginId) { - string pluginSafeName = pluginName.ToLower().Replace("_", " "); + if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == pluginId)) + { + return; + } - TextInfo info = CultureInfo.CurrentCulture.TextInfo; - pluginSafeName = info.ToTitleCase(pluginSafeName); - pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, ""); - - return pluginSafeName; + App.Logger.LogInfo($"Installing plugin {pluginId} required by a shared layout", source: "PluginManager"); + Mod mod = await modIoClient.Games[ModIoGameId].Mods[pluginId].Get(); + await Install(mod); + pluginManagerDataContext.IsLoading = true; } private string GetCsprojPath(string pluginName) diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml b/src/DesktopMagic/Plugins/PluginWindow.xaml index b68b1ee..e02d548 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml @@ -26,7 +26,7 @@ - + diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs index 03df7bc..0b116ca 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs @@ -6,8 +6,13 @@ using DesktopMagic.Helpers; using DesktopMagic.Plugins; using DesktopMagic.Settings; +using SkiaSharp; + using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; using System.Drawing; using System.Drawing.Imaging; using System.IO; @@ -35,8 +40,15 @@ public partial class PluginWindow : Window, IPluginWindow private Thread? pluginThread; private System.Timers.Timer? updateTimer; private Plugin? pluginClassInstance; + private PluginData? pluginData; private AssemblyLoadContext assemblyLoadContext; + private readonly Rectangle screenBounds; + private readonly string screenDeviceName; + private bool isUpdatingPosition = false; + private bool _editMode = false; + private volatile bool renderPaused = false; + private CancellationTokenSource? pluginCancellationTokenSource; private FileSystemWatcher? pluginFileWatcher; private System.Timers.Timer? reloadDebounceTimer; @@ -45,11 +57,37 @@ public partial class PluginWindow : Window, IPluginWindow private WriteableBitmap? writeableBitmap; private BitmapScalingMode lastBitmapScalingMode = BitmapScalingMode.Unspecified; + // Last presented frame bytes; used to skip re-uploading identical frames so WPF does not + // re-upload the texture or re-composite the window (GPU stays idle for static widgets). + private byte[]? lastFramePixels; + private int lastFrameWidth; + private int lastFrameHeight; + private int lastFrameStride; + + // Guards against overlapping render ticks. The timer can re-enter while an async + // render awaits the UI thread, which would run the plugin concurrently and present + // frames out of order. Only one render may be in flight at a time. + private long renderInProgress; + + // Event handlers on long-lived settings objects, tracked so they can be unsubscribed on close. + private readonly PropertyChangedEventHandler settingsPropertyChangedHandler; + private readonly PropertyChangedEventHandler themePropertyChangedHandler; + private Theme? subscribedTheme; + private NotifyCollectionChangedEventHandler? themesCollectionChangedHandler; + private readonly List<(Setting Setting, Action Handler)> subscribedSettings = []; + private readonly List<(Setting Setting, Action Handler)> defaultSettingsSubscriptions = []; + private readonly List<(Button Button, Action Handler)> subscribedButtonClicks = []; + + private readonly ConcurrentDictionary localSettings = []; + private SettingSynchronizer? synchronizer; + private bool suppressButtonSync = false; + public bool IsRunning { get; private set; } = true; public PluginMetadata PluginMetadata { get; private set; } public string PluginFolderPath { get; private set; } + public string ScreenDeviceName => screenDeviceName; - public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath) + public PluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, Rectangle screenBounds, string screenDeviceName) { InitializeComponent(); @@ -69,33 +107,47 @@ public partial class PluginWindow : Window, IPluginWindow Owner = w; - settings.PropertyChanged += (e, s) => + settingsPropertyChangedHandler = (_, s) => { if (s.PropertyName == nameof(PluginSettings.CurrentThemeName)) { - settings.Theme.PropertyChanged += (se, ev) => - { - ThemeChanged(); - }; + SubscribeToTheme(settings.Theme); ThemeChanged(); } + else if (s.PropertyName == nameof(PluginSettings.Position)) + { + UpdatePosition(); + } + else if (s.PropertyName == nameof(PluginSettings.Size)) + { + UpdateSize(); + } }; + settings.PropertyChanged += settingsPropertyChangedHandler; - settings.Theme.PropertyChanged += (se, ev) => - { - ThemeChanged(); - }; + themePropertyChangedHandler = (_, _) => ThemeChanged(); + SubscribeToTheme(settings.Theme); PluginMetadata = pluginMetadata; this.settings = settings; + this.screenBounds = screenBounds; + this.screenDeviceName = screenDeviceName; - Left = settings.Position.X; - Top = settings.Position.Y; - Width = settings.Size.X; - Height = settings.Size.Y; + System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds); + System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds); + Left = position.X; + Top = position.Y; + Width = size.X; + Height = size.Y; PluginFolderPath = pluginFolderPath; + if (settings.Owner is not null) + { + synchronizer = Manager.Instance.GetSettingSynchronizer(settings.Owner, PluginMetadata.Id); + synchronizer.Register(this); + } + assemblyLoadContext = CreateAssemblyLoadContext(); // Initialize hot reload watcher if plugin supports unloading and is external @@ -105,11 +157,27 @@ public partial class PluginWindow : Window, IPluginWindow } } - public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty) + public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings, Rectangle screenBounds, string screenDeviceName) : this(pluginMetadata, settings, string.Empty, screenBounds, screenDeviceName) { this.pluginClassInstance = pluginClassInstance; } + private void SubscribeToTheme(Theme theme) + { + if (ReferenceEquals(subscribedTheme, theme)) + { + return; + } + + if (subscribedTheme is not null) + { + subscribedTheme.PropertyChanged -= themePropertyChangedHandler; + } + + subscribedTheme = theme; + subscribedTheme.PropertyChanged += themePropertyChangedHandler; + } + private AssemblyLoadContext CreateAssemblyLoadContext() { AssemblyLoadContext context = new(PluginMetadata.Name, isCollectible: true); @@ -118,11 +186,10 @@ public partial class PluginWindow : Window, IPluginWindow string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll"); if (File.Exists(assemblyPath)) { - return ctx.LoadFromAssemblyPath(assemblyPath); - } - else - { - _ = ctx.LoadFromAssemblyName(assemblyName); + byte[] assemblyData = File.ReadAllBytes(assemblyPath); + using MemoryStream assemblyStream = new(assemblyData); + + return ctx.LoadFromStream(assemblyStream); } return null; }; @@ -281,6 +348,21 @@ public partial class PluginWindow : Window, IPluginWindow } } + public void SetRenderPaused(bool paused) + { + renderPaused = paused; + + if (paused) + { + updateTimer?.Stop(); + } + else if (IsRunning && pluginClassInstance is { UpdateInterval: > 0 } && updateTimer is not null) + { + updateTimer.Interval = pluginClassInstance.UpdateInterval; + updateTimer.Start(); + } + } + public void UpdatePluginWindow() { UpdateTimer_Elapsed(updateTimer, null); @@ -306,6 +388,8 @@ public partial class PluginWindow : Window, IPluginWindow public void SetEditMode(bool enabled) { + _editMode = enabled; + if (enabled) { Topmost = true; @@ -337,6 +421,9 @@ public partial class PluginWindow : Window, IPluginWindow WindowInteropHelper helper = new(this); _ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE, WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE); + + HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr _, IntPtr lParam, ref bool _) => + ScreenUtilities.ClampToScreenHook(hwnd, msg, lParam, screenBounds)); } private void UpdateImageFromBitmap(Bitmap bitmap, BitmapScalingMode scalingMode) @@ -345,8 +432,28 @@ public partial class PluginWindow : Window, IPluginWindow try { - Dispatcher.Invoke(() => + int stride = bitmapData.Stride; + int height = bitmapData.Height; + int byteCount = stride * height; + + unsafe { + byte* scan0 = (byte*)bitmapData.Scan0.ToPointer(); + + // Skip the present when the frame is byte-identical to the last presented one so WPF + // does not re-upload the texture or re-composite the window (GPU stays idle for + // static widgets that keep ticking). + if (lastFramePixels is { Length: > 0 } + && lastFrameWidth == bitmapData.Width + && lastFrameHeight == height + && lastFrameStride == stride + && lastFramePixels.AsSpan().SequenceEqual(new ReadOnlySpan(scan0, byteCount))) + { + return; + } + + Dispatcher.Invoke(() => + { if (lastBitmapScalingMode != scalingMode) { RenderOptions.SetBitmapScalingMode(image, scalingMode); @@ -355,9 +462,19 @@ public partial class PluginWindow : Window, IPluginWindow if (writeableBitmap == null || writeableBitmap.PixelWidth != bitmapData.Width || writeableBitmap.PixelHeight != bitmapData.Height) { + // A frame sized to the window maps 1:1 to the display when the bitmap uses the + // actual monitor DPI, so WPF skips the (expensive) per-frame scaling filter. + // Smaller content-sized frames keep 96 DPI to preserve their on-screen semantics. + DpiScale dpi = VisualTreeHelper.GetDpi(this); + bool fullWindowFrame = pluginData != null + && bitmapData.Width == pluginData.WindowPixelSize.Width + && bitmapData.Height == pluginData.WindowPixelSize.Height; + double dpiX = fullWindowFrame ? dpi.DpiScaleX * 96 : bitmap.HorizontalResolution; + double dpiY = fullWindowFrame ? dpi.DpiScaleY * 96 : bitmap.VerticalResolution; + writeableBitmap = new WriteableBitmap( bitmapData.Width, bitmapData.Height, - bitmap.HorizontalResolution, bitmap.VerticalResolution, + dpiX, dpiY, PixelFormats.Bgra32, null); image.Source = writeableBitmap; } @@ -379,7 +496,17 @@ public partial class PluginWindow : Window, IPluginWindow { writeableBitmap.Unlock(); } - }); + }); + + if (lastFramePixels is null || lastFramePixels.Length != byteCount) + { + lastFramePixels = new byte[byteCount]; + } + lastFrameWidth = bitmapData.Width; + lastFrameHeight = height; + lastFrameStride = stride; + new ReadOnlySpan(scan0, byteCount).CopyTo(lastFramePixels); + } } finally { @@ -481,7 +608,7 @@ public partial class PluginWindow : Window, IPluginWindow dll = Assembly.LoadFrom($"{PluginFolderPath}\\main.dll"); } - Type? instanceType = Array.Find(dll.GetTypes(), type => type.GetTypeInfo().BaseType == typeof(Plugin)); + Type? instanceType = Array.Find(dll.GetTypes(), type => type.IsAssignableTo(typeof(Plugin))); if (instanceType is null) { @@ -505,7 +632,8 @@ public partial class PluginWindow : Window, IPluginWindow if (instance is Plugin plugin) { pluginClassInstance = plugin; - pluginClassInstance.Application = new PluginData(this, settings); + pluginData = new PluginData(this, settings); + pluginClassInstance.Application = pluginData; } else { @@ -534,6 +662,8 @@ public partial class PluginWindow : Window, IPluginWindow }; updateTimer.Elapsed += UpdateTimer_Elapsed; + await Dispatcher.InvokeAsync(RefreshWindowPixelSize); + pluginClassInstance.Start(); if (pluginClassInstance is AsyncPlugin asyncPluginStart) { @@ -544,7 +674,7 @@ public partial class PluginWindow : Window, IPluginWindow UpdatePluginWindow(); await Dispatcher.InvokeAsync(ThemeChanged); - if (pluginClassInstance.UpdateInterval > 0) + if (pluginClassInstance.UpdateInterval > 0 && !renderPaused) { updateTimer.Interval = pluginClassInstance.UpdateInterval; updateTimer.Start(); @@ -562,14 +692,18 @@ public partial class PluginWindow : Window, IPluginWindow SetThemeOverride(); SetThemeOverrideItems(); - pluginClassInstance.horizontalAlignment.OnValueChanged += SetHorizontalAlignment; - pluginClassInstance.verticalAlignment.OnValueChanged += SetVerticalAlignment; - pluginClassInstance.windowLayer.OnValueChanged += SetWindowLayer; - pluginClassInstance.rotation.OnValueChanged += SetRotation; - pluginClassInstance.themeOverride.OnValueChanged += SetThemeOverride; + SubscribeToDefaultSetting(pluginClassInstance.horizontalAlignment, SetHorizontalAlignment); + SubscribeToDefaultSetting(pluginClassInstance.verticalAlignment, SetVerticalAlignment); + SubscribeToDefaultSetting(pluginClassInstance.windowLayer, SetWindowLayer); + SubscribeToDefaultSetting(pluginClassInstance.rotation, SetRotation); + SubscribeToDefaultSetting(pluginClassInstance.themeOverride, SetThemeOverride); - DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings(); - desktopMagicSettings.Themes.CollectionChanged += (s, e) => SetThemeOverrideItems(); + if (themesCollectionChangedHandler is null) + { + themesCollectionChangedHandler = (_, _) => SetThemeOverrideItems(); + DesktopMagicSettings desktopMagicSettings = MainWindowDataContext.GetSettings(); + desktopMagicSettings.Themes.CollectionChanged += themesCollectionChangedHandler; + } }); void SetVerticalAlignment() @@ -602,7 +736,14 @@ public partial class PluginWindow : Window, IPluginWindow void SetWindowLayer() { - WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value); + if (_editMode) + { + Topmost = true; + } + else + { + WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value); + } } void SetRotation() @@ -636,6 +777,50 @@ public partial class PluginWindow : Window, IPluginWindow } } + private void SubscribeToDefaultSetting(Setting setting, Action handler) + { + setting.OnValueChanged += handler; + defaultSettingsSubscriptions.Add((setting, handler)); + } + + private void OnPluginSettingValueChanged(Setting setting, string id) + { + pluginClassInstance?.OnSettingsChanged(); + + if (pluginClassInstance?.UpdateInterval is 0 or > 500) + { + pluginClassInstance.Application.UpdateWindow(); + } + + synchronizer?.SettingChanged(this, id, setting.GetJsonValue()); + } + + public void ApplySettingValue(string id, string value) + { + if (localSettings.TryGetValue(id, out Setting? setting) && setting.GetJsonValue() != value) + { + setting.SetJsonValue(value); + } + } + + public void ApplyButtonClick(string id) + { + if (!localSettings.TryGetValue(id, out Setting? setting) || setting is not Button button) + { + return; + } + + suppressButtonSync = true; + try + { + button.Click(); + } + finally + { + suppressButtonSync = false; + } + } + private async Task LoadOptions(object instance) { App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin options", source: "Plugin"); @@ -653,23 +838,36 @@ public partial class PluginWindow : Window, IPluginWindow { if (attribute is SettingAttribute elementAttribute) { + localSettings[elementAttribute.Id] = element; + SettingElement settingElement = new SettingElement(element, elementAttribute.Id, elementAttribute.Name, elementAttribute.OrderIndex); if (settings.Settings.Exists(e => e.Id == elementAttribute.Id)) { SettingElement settingsSettingElement = settings.Settings.First(e => e.Id == elementAttribute.Id); - settingElement.JsonValue = settingsSettingElement.JsonValue; + string savedValue = settingsSettingElement.JsonValue; + if (!string.IsNullOrEmpty(savedValue) || element is not Label and not Button) + { + settingElement.JsonValue = savedValue; + } } - element.OnValueChanged += () => - { - pluginClassInstance?.OnSettingsChanged(); + Action valueChangedHandler = () => OnPluginSettingValueChanged(element, elementAttribute.Id); + element.OnValueChanged += valueChangedHandler; + subscribedSettings.Add((element, valueChangedHandler)); - if (pluginClassInstance?.UpdateInterval is 0 or > 500) + if (element is Button button) + { + Action clickHandler = () => { - pluginClassInstance.Application.UpdateWindow(); - } - }; + if (!suppressButtonSync) + { + synchronizer?.ButtonClicked(this, elementAttribute.Id); + } + }; + button.OnClick += clickHandler; + subscribedButtonClicks.Add((button, clickHandler)); + } settingElements.Add(settingElement); break; @@ -830,34 +1028,46 @@ public partial class PluginWindow : Window, IPluginWindow private async void UpdateTimer_Elapsed(object? sender, ElapsedEventArgs? e) { + // Capture the fields into locals so an in-flight tick keeps the plugin assembly + // alive and stays safe when the window is being closed or the plugin reloaded. + Plugin? plugin = pluginClassInstance; + System.Timers.Timer? timer = updateTimer; + + if (!IsRunning || plugin is null) + { + return; + } + + // Drop the tick when a render is still in flight instead of running the plugin + // concurrently (the timer can re-enter while the async render awaits the UI thread). + if (Interlocked.CompareExchange(ref renderInProgress, 1, 0) != 0) + { + return; + } + try { - if (IsRunning && pluginClassInstance is not null) + if (plugin is SkiaPlugin or SkiaAsyncPlugin) + { + await RenderSkiaFrame(); + } + else { Bitmap? result; - if (pluginClassInstance is AsyncPlugin asyncPlugin) + if (plugin is AsyncPlugin asyncPlugin) { CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None; result = await asyncPlugin.MainAsync(token); } else { - result = pluginClassInstance.Main(); - } - - if (pluginClassInstance.UpdateInterval > 0) - { - updateTimer!.Interval = pluginClassInstance.UpdateInterval; - } - else - { - updateTimer!.Stop(); + result = plugin.Main(); } if (result is not null) { - BitmapScalingMode renderOptions = pluginClassInstance.RenderQuality switch + BitmapScalingMode renderOptions = plugin.RenderQuality switch { RenderQuality.High => BitmapScalingMode.HighQuality, RenderQuality.Low => BitmapScalingMode.LowQuality, @@ -868,9 +1078,30 @@ public partial class PluginWindow : Window, IPluginWindow UpdateImageFromBitmap(result, renderOptions); } } + + if (timer is null) + { + return; + } + + if (plugin.UpdateInterval > 0) + { + timer.Interval = plugin.UpdateInterval; + } + else + { + timer.Stop(); + } } catch (Exception ex) { + // If the window is closing, the plugin state is being torn down and the + // exception is a teardown race - ignore it instead of showing an error. + if (!IsRunning) + { + return; + } + IsRunning = false; App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin"); _ = await Dispatcher.InvokeAsync(async () => @@ -884,12 +1115,143 @@ public partial class PluginWindow : Window, IPluginWindow _ = await messageBox.ShowDialogAsync(); }); Exit(); + } + finally + { + Interlocked.Exchange(ref renderInProgress, 0); + } + } + + private async Task RenderSkiaFrame() + { + Plugin? plugin = pluginClassInstance; + + if (plugin is null) + { return; } - if (!IsRunning) + SKImage? skImage; + + if (plugin is SkiaAsyncPlugin skiaAsyncPlugin) { - updateTimer!.Stop(); + CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None; + skImage = await skiaAsyncPlugin.MainAsync(token); + } + else if (plugin is SkiaPlugin skiaPlugin) + { + skImage = skiaPlugin.Main(); + } + else + { + return; + } + + // Null keeps the previous frame, matching how GDI plugins return null from Main(). + if (skImage is null) + { + return; + } + + using (skImage) + { + using SKImage raster = skImage.ToRasterImage(); + using SKPixmap? pixmap = raster.PeekPixels(); + + if (pixmap is null) + { + return; + } + + int width = pixmap.Width; + int height = pixmap.Height; + int byteCount = pixmap.RowBytes * height; + + // Skip the present when the frame is byte-identical to the last presented one so WPF + // does not re-upload the texture or re-composite the window (GPU stays idle for + // static widgets that keep ticking). + if (lastFramePixels is { Length: > 0 } + && lastFrameWidth == width + && lastFrameHeight == height + && lastFrameStride == pixmap.RowBytes + && lastFramePixels.AsSpan().SequenceEqual(pixmap.GetPixelSpan())) + { + return; + } + + BitmapScalingMode scalingMode = plugin.RenderQuality switch + { + RenderQuality.High => BitmapScalingMode.HighQuality, + RenderQuality.Low => BitmapScalingMode.LowQuality, + RenderQuality.Performance => BitmapScalingMode.NearestNeighbor, + _ => BitmapScalingMode.Unspecified + }; + + await Dispatcher.InvokeAsync(() => + { + if (lastBitmapScalingMode != scalingMode) + { + RenderOptions.SetBitmapScalingMode(image, scalingMode); + lastBitmapScalingMode = scalingMode; + } + + if (writeableBitmap is null || writeableBitmap.PixelWidth != width || writeableBitmap.PixelHeight != height) + { + // A frame sized to the window maps 1:1 to the display when the bitmap uses the + // actual monitor DPI, so WPF skips the (expensive) per-frame scaling filter. + // Smaller content-sized frames keep 96 DPI to preserve their on-screen semantics. + DpiScale dpi = VisualTreeHelper.GetDpi(this); + bool fullWindowFrame = pluginData != null + && width == pluginData.WindowPixelSize.Width + && height == pluginData.WindowPixelSize.Height; + double dpiX = fullWindowFrame ? dpi.DpiScaleX * 96 : 96; + double dpiY = fullWindowFrame ? dpi.DpiScaleY * 96 : 96; + + // Skia outputs premultiplied alpha, so use Pbgra32 (premultiplied BGRA). + // Bgra32 is straight alpha and would render semi-transparent pixels too dark. + writeableBitmap = new WriteableBitmap(width, height, dpiX, dpiY, PixelFormats.Pbgra32, null); + image.Source = writeableBitmap; + } + + writeableBitmap.Lock(); + try + { + int destStride = writeableBitmap.BackBufferStride; + int srcStride = pixmap.RowBytes; + int copyWidth = Math.Min(srcStride, destStride); + int copyRows = Math.Min(pixmap.Height, height); + + unsafe + { + byte* src = (byte*)pixmap.GetPixels().ToPointer(); + byte* dst = (byte*)writeableBitmap.BackBuffer.ToPointer(); + + for (int row = 0; row < copyRows; row++) + { + Buffer.MemoryCopy( + src + (row * srcStride), + dst + (row * destStride), + destStride, + copyWidth); + } + } + + writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, width, height)); + } + finally + { + writeableBitmap.Unlock(); + } + }); + + if (lastFramePixels is null || lastFramePixels.Length != byteCount) + { + lastFramePixels = new byte[byteCount]; + } + lastFrameWidth = width; + lastFrameHeight = height; + lastFrameStride = pixmap.RowBytes; + pixmap.GetPixelSpan().CopyTo(lastFramePixels); } } @@ -921,30 +1283,156 @@ public partial class PluginWindow : Window, IPluginWindow reloadDebounceTimer?.Dispose(); reloadDebounceTimer = null; + updateTimer?.Stop(); + updateTimer?.Dispose(); + updateTimer = null; + // Stop plugin using the shared method (no need to await in synchronous event handler) _ = StopPlugin(unloadAssembly: true); + + UnsubscribeEvents(); + DetachSettings(); + + if (synchronizer is not null && settings.Owner is not null) + { + if (synchronizer.Unregister(this)) + { + Manager.Instance.ReleaseSettingSynchronizer(settings.Owner, PluginMetadata.Id); + } + synchronizer = null; + } + } + + private void DetachSettings() + { + foreach (SettingElement settingElement in settings.Settings) + { + string value = settingElement.JsonValue; + settingElement.Input = null; + settingElement.JsonValue = value; + } + } + + private void UnsubscribeEvents() + { + settings.PropertyChanged -= settingsPropertyChangedHandler; + + if (subscribedTheme is not null) + { + subscribedTheme.PropertyChanged -= themePropertyChangedHandler; + subscribedTheme = null; + } + + if (themesCollectionChangedHandler is not null) + { + MainWindowDataContext.GetSettings().Themes.CollectionChanged -= themesCollectionChangedHandler; + themesCollectionChangedHandler = null; + } + + foreach ((Setting Setting, Action Handler) subscription in subscribedSettings) + { + subscription.Setting.OnValueChanged -= subscription.Handler; + } + subscribedSettings.Clear(); + + foreach ((Setting Setting, Action Handler) subscription in defaultSettingsSubscriptions) + { + subscription.Setting.OnValueChanged -= subscription.Handler; + } + defaultSettingsSubscriptions.Clear(); + + foreach ((Button Button, Action Handler) subscription in subscribedButtonClicks) + { + subscription.Button.OnClick -= subscription.Handler; + } + subscribedButtonClicks.Clear(); + + localSettings.Clear(); + } + + private void UpdatePosition() + { + if (isUpdatingPosition) + { + return; + } + + System.Windows.Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds); + if (position == new System.Windows.Point(Left, Top)) + { + return; + } + + isUpdatingPosition = true; + Left = position.X; + Top = position.Y; + isUpdatingPosition = false; + } + + private void UpdateSize() + { + if (isUpdatingPosition) + { + return; + } + + System.Windows.Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds); + if (size == new System.Windows.Point(Width, Height)) + { + return; + } + + isUpdatingPosition = true; + Width = size.X; + Height = size.Y; + isUpdatingPosition = false; } #region Window Events private void Window_LocationChanged(object sender, EventArgs e) { - settings.Position = new System.Windows.Point(Left, Top); + if (isUpdatingPosition) + { + return; + } + + settings.Position = ScreenUtilities.PositionToPercent(new System.Windows.Point(Left, Top), screenBounds); + RefreshWindowPixelSize(); } private void Window_SizeChanged(object sender, SizeChangedEventArgs e) { - settings.Size = new System.Windows.Point(Width, Height); + if (!isUpdatingPosition) + { + settings.Size = ScreenUtilities.SizeToPercent(new System.Windows.Point(Width, Height), screenBounds); + } tileBar.CaptionHeight = ActualHeight - 10; + RefreshWindowPixelSize(); } - private void Window_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + private void RefreshWindowPixelSize() { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + pluginData?.UpdateWindowPixelSize((int)Math.Ceiling(ActualWidth * dpi.DpiScaleX), (int)Math.Ceiling(ActualHeight * dpi.DpiScaleY)); + } + + private void Image_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + _ = image.Focus(); + ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; MouseButton mouseButton; @@ -970,27 +1458,75 @@ public partial class PluginWindow : Window, IPluginWindow pluginClassInstance?.OnMouseClick(point, mouseButton); } - private void Window_MouseMove(object sender, System.Windows.Input.MouseEventArgs e) + private void Image_MouseMove(object sender, System.Windows.Input.MouseEventArgs e) { ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY); pluginClassInstance?.OnMouseMove(point); } - private void Window_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e) + private void Image_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e) { ImageSource imageSource = image.Source; BitmapSource bitmapImage = (BitmapSource)imageSource; - double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / image.ActualHeight; - double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / image.ActualHeight; + double actualWidth = image.ActualWidth; + double actualHeight = image.ActualHeight; + if (actualWidth <= 0 || actualHeight <= 0) + { + return; + } + + double pixelMousePositionX = e.GetPosition(image).X * bitmapImage.PixelWidth / actualWidth; + double pixelMousePositionY = e.GetPosition(image).Y * bitmapImage.PixelHeight / actualHeight; System.Drawing.Point point = new System.Drawing.Point((int)pixelMousePositionX, (int)pixelMousePositionY); pluginClassInstance?.OnMouseWheel(point, e.Delta); } + private void Image_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (pluginClassInstance is null) + { + return; + } + + Keys key = (Keys)e.Key; + System.Windows.Input.ModifierKeys modifiers = e.KeyboardDevice.Modifiers; + bool alt = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt); + bool control = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control); + bool shift = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift); + bool windows = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Windows); + + pluginClassInstance.OnKeyDown(new KeyEventArgs(key, alt, control, shift, windows)); + } + + private void Image_KeyUp(object sender, System.Windows.Input.KeyEventArgs e) + { + if (pluginClassInstance is null) + { + return; + } + + Keys key = (Keys)e.Key; + System.Windows.Input.ModifierKeys modifiers = e.KeyboardDevice.Modifiers; + bool alt = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt); + bool control = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control); + bool shift = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift); + bool windows = modifiers.HasFlag(System.Windows.Input.ModifierKeys.Windows); + + pluginClassInstance.OnKeyUp(new KeyEventArgs(key, alt, control, shift, windows)); + } + #endregion Window Events } \ No newline at end of file diff --git a/src/DesktopMagic/Plugins/SettingElement.cs b/src/DesktopMagic/Plugins/SettingElement.cs index cbc45a6..7778177 100644 --- a/src/DesktopMagic/Plugins/SettingElement.cs +++ b/src/DesktopMagic/Plugins/SettingElement.cs @@ -7,15 +7,27 @@ namespace DesktopMagic.Plugins; public class SettingElement { private string jsonValue = string.Empty; + private bool shareable; [JsonIgnore] - public Setting Input { get; set; } + public Setting? Input { get; set; } public string Name { get; set; } public int OrderIndex { get; set; } public string Id { get; set; } + /// + /// Whether the value may be included when sharing a layout on mod.io. + /// Free-text values (URLs, locations, file paths, ...) can contain personal data and are never shared. + /// Persisted so the decision stays stable while the plugin is not loaded. + /// + public bool Shareable + { + get => Input is null ? shareable : IsShareableInput(Input); + set => shareable = value; + } + public string JsonValue { get => Input?.GetJsonValue() ?? jsonValue; @@ -40,6 +52,11 @@ public class SettingElement OrderIndex = orderIndex; } + private static bool IsShareableInput(Setting input) + { + return input is CheckBox or ColorPicker or ComboBox or IntegerUpDown or Slider; + } + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. [JsonConstructor] diff --git a/src/DesktopMagic/Plugins/SettingSynchronizer.cs b/src/DesktopMagic/Plugins/SettingSynchronizer.cs new file mode 100644 index 0000000..aca1bfe --- /dev/null +++ b/src/DesktopMagic/Plugins/SettingSynchronizer.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Threading; + +namespace DesktopMagic.Plugins; + +/// +/// Keeps the per-instance plugin settings of every window sharing the same layout in sync. +/// Each window owns its own objects (plugins +/// hold them in readonly fields and subscribe to them in Start), so a value change on one +/// window has to be mirrored onto the sibling windows. +/// +internal sealed class SettingSynchronizer +{ + private readonly object _lock = new(); + private readonly List windows = []; + private readonly HashSet<(IPluginWindow Window, string Id)> inFlight = []; + + /// + /// Registers a window so it receives mirrored settings from the other windows of this layout. + /// + public void Register(IPluginWindow window) + { + lock (_lock) + { + if (!windows.Contains(window)) + { + windows.Add(window); + } + } + } + + /// + /// Unregisters a window and returns whether no windows remain. + /// + public bool Unregister(IPluginWindow window) + { + lock (_lock) + { + windows.Remove(window); + return windows.Count == 0; + } + } + + /// + /// Mirrors a setting value change onto every other registered window. + /// + public void SettingChanged(IPluginWindow origin, string id, string value) + { + Mirror(origin, id, window => window.ApplySettingValue(id, value)); + } + + /// + /// Mirrors a button click onto every other registered window. + /// + public void ButtonClicked(IPluginWindow origin, string id) + { + Mirror(origin, id, window => window.ApplyButtonClick(id)); + } + + private void Mirror(IPluginWindow origin, string id, Action apply) + { + IPluginWindow[] snapshot; + lock (_lock) + { + snapshot = windows.ToArray(); + } + + foreach (IPluginWindow window in snapshot) + { + if (ReferenceEquals(window, origin) || !window.IsRunning) + { + continue; + } + + lock (_lock) + { + if (!inFlight.Add((window, id))) + { + continue; + } + } + + if (window is not DispatcherObject dispatcherObject) + { + lock (_lock) + { + inFlight.Remove((window, id)); + } + continue; + } + + _ = dispatcherObject.Dispatcher.InvokeAsync(() => + { + try + { + apply(window); + } + finally + { + lock (_lock) + { + inFlight.Remove((window, id)); + } + } + }); + } + } +} diff --git a/src/DesktopMagic/Plugins/Theme.cs b/src/DesktopMagic/Plugins/Theme.cs index 9f96709..8fadc22 100644 --- a/src/DesktopMagic/Plugins/Theme.cs +++ b/src/DesktopMagic/Plugins/Theme.cs @@ -1,5 +1,6 @@ using DesktopMagic.Api; using DesktopMagic.Helpers; +using DesktopMagic.Settings; using System.ComponentModel; using System.Runtime.CompilerServices; @@ -11,7 +12,7 @@ using Color = System.Drawing.Color; namespace DesktopMagic.Plugins; -public class Theme(string name) : ITheme, INotifyPropertyChanged +public class Theme(string name) : ITheme, INotifyPropertyChanged, IModIoShareable { public event PropertyChangedEventHandler? PropertyChanged; @@ -21,6 +22,23 @@ public class Theme(string name) : ITheme, INotifyPropertyChanged private string font = "Segoe UI"; private int cornerRadius; private int margin; + private ModIoShareState shareState; + + public ModIoLink? ModIo { get; set; } + + [JsonIgnore] + public ModIoShareState ShareState + { + get => shareState; + set + { + if (shareState != value) + { + shareState = value; + OnPropertyChanged(); + } + } + } public string Name { diff --git a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs index 08f0b5a..78c2a48 100644 --- a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs @@ -6,7 +6,9 @@ using DesktopMagic.Settings; using Microsoft.Web.WebView2.Core; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Text.Json; @@ -29,11 +31,27 @@ public partial class WebPluginWindow : Window, IPluginWindow private System.Timers.Timer? reloadDebounceTimer; private bool isReloading = false; + private readonly System.Drawing.Rectangle screenBounds; + private readonly string screenDeviceName; + private bool isUpdatingPosition = false; + + // Event handlers on long-lived settings objects, tracked so they can be unsubscribed on close. + private readonly PropertyChangedEventHandler settingsPropertyChangedHandler; + private readonly PropertyChangedEventHandler themePropertyChangedHandler; + private Theme? subscribedTheme; + private readonly List<(Setting Setting, Action Handler)> subscribedSettings = []; + private readonly List<(Button Button, Action Handler)> subscribedButtonClicks = []; + + private readonly ConcurrentDictionary localSettings = []; + private SettingSynchronizer? synchronizer; + private bool suppressButtonSync = false; + public bool IsRunning { get; private set; } = true; public PluginMetadata PluginMetadata { get; private set; } public string PluginFolderPath { get; private set; } + public string ScreenDeviceName => screenDeviceName; - public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath) + public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath, System.Drawing.Rectangle screenBounds, string screenDeviceName) { InitializeComponent(); @@ -53,39 +71,69 @@ public partial class WebPluginWindow : Window, IPluginWindow Owner = w; - settings.PropertyChanged += (e, s) => + settingsPropertyChangedHandler = (_, s) => { if (s.PropertyName == nameof(PluginSettings.CurrentThemeName)) { - settings.Theme.PropertyChanged += (se, ev) => - { - ThemeChanged(); - }; + SubscribeToTheme(settings.Theme); ThemeChanged(); } + else if (s.PropertyName == nameof(PluginSettings.Position)) + { + UpdatePosition(); + } + else if (s.PropertyName == nameof(PluginSettings.Size)) + { + UpdateSize(); + } }; + settings.PropertyChanged += settingsPropertyChangedHandler; - settings.Theme.PropertyChanged += (se, ev) => - { - ThemeChanged(); - }; + themePropertyChangedHandler = (_, _) => ThemeChanged(); + SubscribeToTheme(settings.Theme); PluginMetadata = pluginMetadata; this.settings = settings; + this.screenBounds = screenBounds; + this.screenDeviceName = screenDeviceName; - Left = settings.Position.X; - Top = settings.Position.Y; - Width = settings.Size.X; - Height = settings.Size.Y; + Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds); + Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds); + Left = position.X; + Top = position.Y; + Width = size.X; + Height = size.Y; PluginFolderPath = pluginFolderPath; + if (settings.Owner is not null) + { + synchronizer = Manager.Instance.GetSettingSynchronizer(settings.Owner, PluginMetadata.Id); + synchronizer.Register(this); + } + if (pluginMetadata.SupportsUnloading && !string.IsNullOrEmpty(pluginFolderPath)) { InitializeHotReload(); } } + private void SubscribeToTheme(Theme theme) + { + if (ReferenceEquals(subscribedTheme, theme)) + { + return; + } + + if (subscribedTheme is not null) + { + subscribedTheme.PropertyChanged -= themePropertyChangedHandler; + } + + subscribedTheme = theme; + subscribedTheme.PropertyChanged += themePropertyChangedHandler; + } + public void Exit() { IsRunning = false; @@ -120,6 +168,49 @@ public partial class WebPluginWindow : Window, IPluginWindow } } + public void ApplySettingValue(string id, string value) + { + if (localSettings.TryGetValue(id, out Setting? setting) && setting.GetJsonValue() != value) + { + setting.SetJsonValue(value); + } + } + + public void ApplyButtonClick(string id) + { + if (!localSettings.TryGetValue(id, out Setting? setting) || setting is not Button button) + { + return; + } + + suppressButtonSync = true; + try + { + button.Click(); + } + finally + { + suppressButtonSync = false; + } + } + + public void SetRenderPaused(bool paused) + { + if (webView.CoreWebView2 is null) + { + return; + } + + if (paused) + { + _ = webView.CoreWebView2.TrySuspendAsync(); + } + else + { + webView.CoreWebView2.Resume(); + } + } + protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); @@ -127,6 +218,9 @@ public partial class WebPluginWindow : Window, IPluginWindow WindowInteropHelper helper = new(this); _ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE, WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE); + + HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr _, IntPtr lParam, ref bool _) => + ScreenUtilities.ClampToScreenHook(hwnd, msg, lParam, screenBounds)); } private async void Window_ContentRendered(object? sender, EventArgs e) @@ -249,6 +343,18 @@ public partial class WebPluginWindow : Window, IPluginWindow reloadDebounceTimer?.Stop(); reloadDebounceTimer?.Dispose(); + reloadDebounceTimer = null; + + UnsubscribeEvents(); + + if (synchronizer is not null && settings.Owner is not null) + { + if (synchronizer.Unregister(this)) + { + Manager.Instance.ReleaseSettingSynchronizer(settings.Owner, PluginMetadata.Id); + } + synchronizer = null; + } try { @@ -263,14 +369,85 @@ public partial class WebPluginWindow : Window, IPluginWindow } } + private void UnsubscribeEvents() + { + settings.PropertyChanged -= settingsPropertyChangedHandler; + + if (subscribedTheme is not null) + { + subscribedTheme.PropertyChanged -= themePropertyChangedHandler; + subscribedTheme = null; + } + + foreach ((Setting setting, Action handler) in subscribedSettings) + { + setting.OnValueChanged -= handler; + } + subscribedSettings.Clear(); + + foreach ((Button button, Action handler) in subscribedButtonClicks) + { + button.OnClick -= handler; + } + subscribedButtonClicks.Clear(); + + localSettings.Clear(); + } + + private void UpdatePosition() + { + if (isUpdatingPosition) + { + return; + } + + Point position = ScreenUtilities.PercentToPosition(settings.Position, screenBounds); + if (position == new Point(Left, Top)) + { + return; + } + + isUpdatingPosition = true; + Left = position.X; + Top = position.Y; + isUpdatingPosition = false; + } + + private void UpdateSize() + { + if (isUpdatingPosition) + { + return; + } + + Point size = ScreenUtilities.PercentSizeToSize(settings.Size, screenBounds); + if (size == new Point(Width, Height)) + { + return; + } + + isUpdatingPosition = true; + Width = size.X; + Height = size.Y; + isUpdatingPosition = false; + } + private void Window_LocationChanged(object sender, EventArgs e) { - settings.Position = new System.Windows.Point(Left, Top); + if (isUpdatingPosition) + { + return; + } + + settings.Position = ScreenUtilities.PositionToPercent(new Point(Left, Top), screenBounds); } private void Window_SizeChanged(object sender, SizeChangedEventArgs e) { - settings.Size = new System.Windows.Point(Width, Height); + if (!isUpdatingPosition) + { + settings.Size = ScreenUtilities.SizeToPercent(new Point(Width, Height), screenBounds); + } tileBar.CaptionHeight = ActualHeight - 10; } @@ -330,19 +507,30 @@ public partial class WebPluginWindow : Window, IPluginWindow continue; } + localSettings[id] = setting; + SettingElement settingElement = new(setting, id, name, orderIndex); if (settings.Settings.Exists(e => e.Id == id)) { SettingElement saved = settings.Settings.First(e => e.Id == id); - settingElement.JsonValue = saved.JsonValue; + string savedValue = saved.JsonValue; + if (!string.IsNullOrEmpty(savedValue) || setting is not Label and not Button) + { + settingElement.JsonValue = savedValue; + } } string capturedId = id; if (setting is Button button) { - button.OnClick += () => + Action clickHandler = () => { + if (!suppressButtonSync) + { + synchronizer?.ButtonClicked(this, capturedId); + } + _ = webView.Dispatcher.InvokeAsync(async () => { try @@ -355,10 +543,14 @@ public partial class WebPluginWindow : Window, IPluginWindow } }); }; + button.OnClick += clickHandler; + subscribedButtonClicks.Add((button, clickHandler)); } - setting.OnValueChanged += () => + Action valueChangedHandler = () => { + synchronizer?.SettingChanged(this, capturedId, setting.GetJsonValue()); + _ = webView.Dispatcher.InvokeAsync(async () => { try @@ -371,6 +563,8 @@ public partial class WebPluginWindow : Window, IPluginWindow } }); }; + setting.OnValueChanged += valueChangedHandler; + subscribedSettings.Add((setting, valueChangedHandler)); settingElements.Add(settingElement); orderIndex++; @@ -540,7 +734,7 @@ public partial class WebPluginWindow : Window, IPluginWindow Dictionary dict = []; foreach (SettingElement element in settings.Settings) { - dict[element.Id] = GetSettingValue(element.Input); + dict[element.Id] = GetSettingValue(element.Input!); } return JsonSerializer.Serialize(dict); } diff --git a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml index 96d9736..b0cccff 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml @@ -40,6 +40,8 @@ Wetter Nächstes Meeting Countdown Agenda + Performance Benchmark + SkiaSharp Benchmark Signalverstärkung: Neues Layout Layout Löschen @@ -53,6 +55,7 @@ Möchten Sie dieses Theme wirklich löschen? Layoutnamen eingeben: Layout existiert bereits! + Das leere Layout kann nicht gelöscht werden! Themenamen eingeben: Theme existiert bereits! Installieren @@ -67,6 +70,30 @@ Veröffentlicht: {0} Release Notes: Release-Seite öffnen + Schließen + Überschreiben + Meine behalten + Auf mod.io teilen + Auf mod.io aktualisieren + Auf mod.io ansehen + Als neu auf mod.io teilen + Kurze Zusammenfassung für mod.io eingeben: + Was hat sich geändert? (optional) + Jeder auf mod.io kann dieses Layout herunterladen. Es enthält die aktivierten Widgets, ihre Positionen, die verwendeten Themes und ihre Nicht-Text-Einstellungen. Text-Einstellungen (z. B. URLs, Orte oder Dateipfade) und gespeicherte Widget-Daten werden nicht geteilt. + Jeder auf mod.io kann dieses Theme herunterladen. + Diese Widgets sind lokale Plugins, die andere nicht installieren können: {0} + "{0}" wurde auf mod.io veröffentlicht. + Das Update von "{0}" wurde auf mod.io veröffentlicht. + Teilen auf mod.io fehlgeschlagen: {0} + Ihre mod.io-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an. + "{0}" wurde auf mod.io aktualisiert, aber Sie haben es auch lokal geändert. Ihre Änderungen mit der neuen Version überschreiben? + Das leere Layout kann nicht geteilt werden! + Layouts + mod.io durchsuchen... + Installierte durchsuchen... + Nichts gefunden! + Installation von "{0}" fehlgeschlagen: {1} + Wird auf mod.io hochgeladen... Unten Mitte diff --git a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml index a048d3d..ca28a1d 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml @@ -40,6 +40,8 @@ Weather Next Meeting Countdown Agenda + Performance Benchmark + SkiaSharp Benchmark Signal Amplification: New Layout Delete Layout @@ -54,6 +56,7 @@ Enter layout name Layout already exists! Can't delete last layout! + Can't delete the empty layout! Enter theme name Theme already exists! Can't delete last theme! @@ -69,6 +72,30 @@ Published: {0} Release notes: Open release page + Close + Overwrite + Keep mine + Share on mod.io + Update on mod.io + View on mod.io + Share as new on mod.io + Enter a short summary shown on mod.io: + What changed? (optional) + Everyone on mod.io will be able to download this layout. It includes the enabled widgets, their positions, the used themes and their non-text settings. Text settings (e.g. URLs, locations or file paths) and saved widget data are not shared. + Everyone on mod.io will be able to download this theme. + These widgets are local plugins that others cannot install: {0} + "{0}" was published on mod.io. + The update of "{0}" was published on mod.io. + Sharing on mod.io failed: {0} + Your mod.io session has expired. Please log in again. + "{0}" was updated on mod.io, but you also changed it locally. Overwrite your changes with the new version? + The empty layout cannot be shared! + Layouts + Search mod.io... + Search installed... + Nothing found! + Installing "{0}" failed: {1} + Uploading to mod.io... Bottom Middle diff --git a/src/DesktopMagic/Settings/DesktopMagicSettings.cs b/src/DesktopMagic/Settings/DesktopMagicSettings.cs index 1cb61f6..ad18bf5 100644 --- a/src/DesktopMagic/Settings/DesktopMagicSettings.cs +++ b/src/DesktopMagic/Settings/DesktopMagicSettings.cs @@ -1,5 +1,6 @@ using DesktopMagic.Plugins; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; @@ -22,7 +23,13 @@ public class DesktopMagicSettings : INotifyPropertyChanged init { themes = value; - themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme(); + themes.CollectionChanged += (s, e) => + { + foreach (Layout layout in layouts) + { + layout.UpdateTheme(); + } + }; OnPropertyChanged(); } } @@ -62,13 +69,33 @@ public class DesktopMagicSettings : INotifyPropertyChanged } } + /// + /// Maps screen device names (e.g. "\\.\DISPLAY1") to the layout applied on that screen. + /// The layouts themselves are portable and store their target aspect ratio. + /// + public Dictionary ScreenLayouts { get; set; } = []; + + /// + /// Version of the settings schema, used to migrate older settings files. + /// 0 = legacy (layouts not screen aware, pixel based positions). + /// + public int SchemaVersion { get; set; } + public string? ModIoAccessToken { get; set; } public string? ReleaseInfoLastAppVersion { get; set; } + public bool IsFirstRun { get; set; } = true; + public DesktopMagicSettings() { - themes.CollectionChanged += (s, e) => CurrentLayout.UpdateTheme(); + themes.CollectionChanged += (s, e) => + { + foreach (Layout layout in layouts) + { + layout.UpdateTheme(); + } + }; layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayout)); layouts.CollectionChanged += (s, e) => OnPropertyChanged(nameof(CurrentLayoutName)); diff --git a/src/DesktopMagic/Settings/Layout.cs b/src/DesktopMagic/Settings/Layout.cs index a92dff7..8191793 100644 --- a/src/DesktopMagic/Settings/Layout.cs +++ b/src/DesktopMagic/Settings/Layout.cs @@ -9,13 +9,31 @@ using System.Text.Json.Serialization; namespace DesktopMagic.Settings; -public class Layout(string name) : INotifyPropertyChanged +public class Layout(string name) : INotifyPropertyChanged, IModIoShareable { public event PropertyChangedEventHandler? PropertyChanged; private string name = name; private string? currentThemeName = null; private Dictionary plugins = []; + private double screenAspectRatio = 0; + private ModIoShareState shareState; + + public ModIoLink? ModIo { get; set; } + + [JsonIgnore] + public ModIoShareState ShareState + { + get => shareState; + set + { + if (shareState != value) + { + shareState = value; + OnPropertyChanged(); + } + } + } [JsonIgnore] public Theme Theme @@ -60,6 +78,23 @@ public class Layout(string name) : INotifyPropertyChanged } } + /// + /// Aspect ratio (Width / Height) of the screen this layout was designed for. + /// Used to match layouts to screens and to make layouts portable across screens. + /// + public double ScreenAspectRatio + { + get => screenAspectRatio; + set + { + if (screenAspectRatio != value) + { + screenAspectRatio = value; + OnPropertyChanged(); + } + } + } + public void UpdatePlugins() { plugins = plugins.ToDictionary(); diff --git a/src/DesktopMagic/Settings/ModIoLink.cs b/src/DesktopMagic/Settings/ModIoLink.cs new file mode 100644 index 0000000..80cd8cd --- /dev/null +++ b/src/DesktopMagic/Settings/ModIoLink.cs @@ -0,0 +1,48 @@ +using System; + +namespace DesktopMagic.Settings; + +/// +/// Connects a local layout or theme to the mod.io mod it was published as or downloaded from. +/// +public class ModIoLink +{ + public uint ModId { get; set; } + + /// + /// The modfile (version) the local copy is based on. + /// + public uint ModfileId { get; set; } + + /// + /// Hash of the shareable content at the last publish or download, used to detect local changes. + /// + public string? PublishedHash { get; set; } + + /// + /// Whether the logged in mod.io user submitted the mod and is therefore allowed to upload new versions. + /// + public bool IsOwner { get; set; } + + public Uri? ProfileUri { get; set; } +} + +public enum ModIoShareState +{ + NotShared, + UpToDate, + Modified, + NotOwner +} + +/// +/// A local item (layout or theme) that can be shared on mod.io. +/// +public interface IModIoShareable +{ + string Name { get; } + + ModIoLink? ModIo { get; set; } + + ModIoShareState ShareState { get; set; } +} diff --git a/src/DesktopMagic/Settings/PluginSettings.cs b/src/DesktopMagic/Settings/PluginSettings.cs index 5655032..dac3bb3 100644 --- a/src/DesktopMagic/Settings/PluginSettings.cs +++ b/src/DesktopMagic/Settings/PluginSettings.cs @@ -19,20 +19,24 @@ public class PluginSettings : INotifyPropertyChanged private List settings = []; private Dictionary state = []; private bool enabled = false; - private Point position = new Point(100, 100); - private Point size = new Point(300, 300); + private Point position = new Point(0.05, 0.05); + private Point size = new Point(0.3, 0.3); // Only for internal use to show the name of the plugin in the main window [JsonIgnore] public PluginMetadata Metadata { get; set; } = new(); + // The layout this plugin belongs to, used to resolve the fallback theme + [JsonIgnore] + public Layout? Owner { get; set; } + [JsonIgnore] public Theme Theme { get { DesktopMagicSettings settings = MainWindowDataContext.GetSettings(); - return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? settings.CurrentLayout.Theme; + return settings.Themes.FirstOrDefault(t => t.Name == currentThemeName) ?? Owner?.Theme ?? settings.CurrentLayout.Theme; } } @@ -93,6 +97,9 @@ public class PluginSettings : INotifyPropertyChanged } } + /// + /// Position of the plugin window as percentages (0..1) of the owning screen's bounds. + /// public Point Position { get => position; @@ -106,6 +113,9 @@ public class PluginSettings : INotifyPropertyChanged } } + /// + /// Size of the plugin window as percentages (0..1) of the owning screen's bounds. + /// public Point Size { get => size; diff --git a/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj b/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj index 1cd85a0..6354a24 100644 --- a/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj +++ b/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj @@ -4,11 +4,11 @@ net8.0-windows Stone_Red Stone_Red - 1.0.0.0 + 1.1.0.0 https://github.com/Stone-Red-Code/DesktopMagic LICENSE true - 1.0.0.0 + 1.1.0.0 enable @@ -49,6 +49,7 @@ + diff --git a/src/DesktopMagicPluginAPI/IPluginData.cs b/src/DesktopMagicPluginAPI/IPluginData.cs index 10a6639..bd1478e 100644 --- a/src/DesktopMagicPluginAPI/IPluginData.cs +++ b/src/DesktopMagicPluginAPI/IPluginData.cs @@ -13,10 +13,16 @@ public interface IPluginData ITheme Theme { get; } /// - /// Gets the window size of the plugin window. + /// Gets the window size of the plugin window in device independent pixels. /// Size WindowSize { get; } + /// + /// Gets the window size of the plugin window in physical (device) pixels. + /// Use this to create a rendering surface that maps 1:1 to the display. + /// + Size WindowPixelSize { get; } + /// /// Gets the window position of the plugin window. /// diff --git a/src/DesktopMagicPluginAPI/KeyEventArgs.cs b/src/DesktopMagicPluginAPI/KeyEventArgs.cs new file mode 100644 index 0000000..8396e24 --- /dev/null +++ b/src/DesktopMagicPluginAPI/KeyEventArgs.cs @@ -0,0 +1,46 @@ +using System; + +namespace DesktopMagic.Api; + +/// +/// Provides data for keyboard events. +/// +public class KeyEventArgs : EventArgs +{ + /// + /// The key that was pressed or released. + /// + public Keys Key { get; } + + /// + /// Whether the Alt modifier was pressed. + /// + public bool Alt { get; } + + /// + /// Whether the Control modifier was pressed. + /// + public bool Control { get; } + + /// + /// Whether the Shift modifier was pressed. + /// + public bool Shift { get; } + + /// + /// Whether the Windows logo key was pressed. + /// + public bool Windows { get; } + + /// + /// Initializes a new instance of the class. + /// + public KeyEventArgs(Keys key, bool alt, bool control, bool shift, bool windows) + { + Key = key; + Alt = alt; + Control = control; + Shift = shift; + Windows = windows; + } +} diff --git a/src/DesktopMagicPluginAPI/Keys.cs b/src/DesktopMagicPluginAPI/Keys.cs new file mode 100644 index 0000000..1a33fac --- /dev/null +++ b/src/DesktopMagicPluginAPI/Keys.cs @@ -0,0 +1,160 @@ +#pragma warning disable CS1591 + +namespace DesktopMagic.Api; + +/// +/// Defines keyboard keys available for plugin input handling. +/// Values match System.Windows.Input.Key for direct casting. +/// +public enum Keys +{ + None = 0, + Cancel = 1, + Back = 2, + Tab = 3, + LineFeed = 4, + Clear = 5, + Return = 6, + Enter = 6, + ShiftKey = 16, + ControlKey = 17, + AltKey = 18, + Pause = 19, + CapsLock = 20, + Escape = 27, + Space = 32, + PageUp = 33, + PageDown = 34, + End = 35, + Home = 36, + Left = 37, + Up = 38, + Right = 39, + Down = 40, + Select = 41, + Print = 42, + Execute = 43, + PrintScreen = 44, + Insert = 45, + Delete = 46, + Help = 47, + D0 = 48, + D1 = 49, + D2 = 50, + D3 = 51, + D4 = 52, + D5 = 53, + D6 = 54, + D7 = 55, + D8 = 56, + D9 = 57, + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + LWin = 91, + RWin = 92, + Apps = 93, + Sleep = 95, + NumPad0 = 96, + NumPad1 = 97, + NumPad2 = 98, + NumPad3 = 99, + NumPad4 = 100, + NumPad5 = 101, + NumPad6 = 102, + NumPad7 = 103, + NumPad8 = 104, + NumPad9 = 105, + Multiply = 106, + Add = 107, + Separator = 108, + Subtract = 109, + Decimal = 110, + Divide = 111, + F1 = 112, + F2 = 113, + F3 = 114, + F4 = 115, + F5 = 116, + F6 = 117, + F7 = 118, + F8 = 119, + F9 = 120, + F10 = 121, + F11 = 122, + F12 = 123, + F13 = 124, + F14 = 125, + F15 = 126, + F16 = 127, + F17 = 128, + F18 = 129, + F19 = 130, + F20 = 131, + F21 = 132, + F22 = 133, + F23 = 134, + F24 = 135, + NumLock = 144, + Scroll = 145, + LeftShift = 160, + RightShift = 161, + LeftCtrl = 162, + RightCtrl = 163, + LeftAlt = 164, + RightAlt = 165, + BrowserBack = 166, + BrowserForward = 167, + BrowserRefresh = 168, + BrowserStop = 169, + BrowserSearch = 170, + BrowserFavorites = 171, + BrowserHome = 172, + VolumeMute = 173, + VolumeDown = 174, + VolumeUp = 175, + MediaNextTrack = 176, + MediaPreviousTrack = 177, + MediaStop = 178, + MediaPlayPause = 179, + LaunchMail = 180, + SelectMedia = 181, + LaunchApplication1 = 182, + LaunchApplication2 = 183, + OemSemicolon = 186, + OemPlus = 187, + OemComma = 188, + OemMinus = 189, + OemPeriod = 190, + OemQuestion = 191, + OemTilde = 192, + OemOpenBrackets = 219, + OemPipe = 220, + OemCloseBrackets = 221, + OemQuotes = 222, + Oem8 = 223, + OemBackslash = 226, +} diff --git a/src/DesktopMagicPluginAPI/Plugin.cs b/src/DesktopMagicPluginAPI/Plugin.cs index 94cbb09..935199e 100644 --- a/src/DesktopMagicPluginAPI/Plugin.cs +++ b/src/DesktopMagicPluginAPI/Plugin.cs @@ -99,6 +99,22 @@ public abstract class Plugin { } + /// + /// Occurs when a key is pressed while the plugin window has keyboard focus. + /// + /// The key event data. + public virtual void OnKeyDown(KeyEventArgs e) + { + } + + /// + /// Occurs when a key is released while the plugin window has keyboard focus. + /// + /// The key event data. + public virtual void OnKeyUp(KeyEventArgs e) + { + } + /// /// Occurs when the application's theme has changed. /// diff --git a/src/DesktopMagicPluginAPI/Settings/Button.cs b/src/DesktopMagicPluginAPI/Settings/Button.cs index a2297cc..924db85 100644 --- a/src/DesktopMagicPluginAPI/Settings/Button.cs +++ b/src/DesktopMagicPluginAPI/Settings/Button.cs @@ -46,4 +46,14 @@ public class Button : Setting { OnClick?.Invoke(); } + + internal override string GetJsonValue() + { + return Value; + } + + internal override void SetJsonValue(string value) + { + Value = value; + } } \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/Settings/IntegerUpDown.cs b/src/DesktopMagicPluginAPI/Settings/IntegerUpDown.cs index 1361d45..0da6fc8 100644 --- a/src/DesktopMagicPluginAPI/Settings/IntegerUpDown.cs +++ b/src/DesktopMagicPluginAPI/Settings/IntegerUpDown.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace DesktopMagic.Api.Settings; @@ -64,12 +65,17 @@ public class IntegerUpDown : Setting internal override string GetJsonValue() { - return Value.ToString(); + return Value.ToString(CultureInfo.InvariantCulture); } internal override void SetJsonValue(string value) { - _ = int.TryParse(value, out int result); + // Values saved by older versions use the current culture (e.g. a culture specific minus sign), so fall back to it. + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)) + { + _ = int.TryParse(value, NumberStyles.Integer, CultureInfo.CurrentCulture, out result); + } + Value = result; } } \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/Settings/Label.cs b/src/DesktopMagicPluginAPI/Settings/Label.cs index 0547c9b..2db66f2 100644 --- a/src/DesktopMagicPluginAPI/Settings/Label.cs +++ b/src/DesktopMagicPluginAPI/Settings/Label.cs @@ -38,4 +38,14 @@ public class Label : Setting Value = value; Bold = bold; } + + internal override string GetJsonValue() + { + return Value; + } + + internal override void SetJsonValue(string value) + { + Value = value; + } } \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/Settings/Slider.cs b/src/DesktopMagicPluginAPI/Settings/Slider.cs index 003a58a..f066dc7 100644 --- a/src/DesktopMagicPluginAPI/Settings/Slider.cs +++ b/src/DesktopMagicPluginAPI/Settings/Slider.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Globalization; namespace DesktopMagic.Api.Settings; @@ -65,12 +66,18 @@ public sealed class Slider : Setting internal override string GetJsonValue() { - return Value.ToString(); + return Value.ToString(CultureInfo.InvariantCulture); } internal override void SetJsonValue(string value) { - _ = double.TryParse(value, out double result); + // Values saved by older versions use the current culture (e.g. "30,5"), so fall back to it. + // Thousands separators are not allowed, otherwise "30,5" would be read as 305 by the invariant culture. + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + _ = double.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out result); + } + Value = result; } } \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs b/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs new file mode 100644 index 0000000..1cb7ff7 --- /dev/null +++ b/src/DesktopMagicPluginAPI/SkiaAsyncPlugin.cs @@ -0,0 +1,48 @@ +using SkiaSharp; + +using System.ComponentModel; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.Api; + +/// +/// Infrastructure base class of . Derive from instead. +/// +/// This class only exists to seal the based method, +/// so that can declare its own method returning an . +[EditorBrowsable(EditorBrowsableState.Never)] +public abstract class SkiaAsyncPluginBase : AsyncPlugin +{ + /// + /// This method should not be called directly! Override and use for asynchronous Skia plugin rendering. + /// + /// This method only exists to fulfill base class requirements. + /// Token signaled when the host requests cancellation. + /// A task that always resolves to . + public sealed override Task MainAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } +} + +/// +/// Provides an abstract base class for creating asynchronous plugins that render with SkiaSharp and 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. The image returned by is scaled to fit the +/// plugin window while preserving its aspect ratio. +public abstract class SkiaAsyncPlugin : SkiaAsyncPluginBase +{ + /// + /// Occurs when the elapses. + /// + /// Token signaled when the host requests cancellation. + /// The rendered image, or to keep the previous frame. + public new abstract Task MainAsync(CancellationToken cancellationToken); +} diff --git a/src/DesktopMagicPluginAPI/SkiaPlugin.cs b/src/DesktopMagicPluginAPI/SkiaPlugin.cs new file mode 100644 index 0000000..334a593 --- /dev/null +++ b/src/DesktopMagicPluginAPI/SkiaPlugin.cs @@ -0,0 +1,44 @@ +using SkiaSharp; + +using System.ComponentModel; +using System.Drawing; + +namespace DesktopMagic.Api; + +/// +/// Infrastructure base class of . Derive from instead. +/// +/// This class only exists to seal the based method, +/// so that can declare its own method returning an . +[EditorBrowsable(EditorBrowsableState.Never)] +public abstract class SkiaPluginBase : Plugin +{ + /// + /// This method should not be called directly! Override and use for Skia plugin rendering. + /// + /// This method only exists to fulfill base class requirements. + /// Always . + public sealed override Bitmap? Main() + { + return null; + } +} + +/// +/// Provides an abstract base class for creating plugins that render with SkiaSharp and 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. The image returned by is scaled to fit the +/// plugin window while preserving its aspect ratio. +public abstract class SkiaPlugin : SkiaPluginBase +{ + /// + /// Occurs when the elapses. + /// + /// The rendered image, or to keep the previous frame. + public new abstract SKImage? Main(); +} diff --git a/src/MsixPackaging/Package.appxmanifest b/src/MsixPackaging/Package.appxmanifest index 5e047ab..1f608e7 100644 --- a/src/MsixPackaging/Package.appxmanifest +++ b/src/MsixPackaging/Package.appxmanifest @@ -9,7 +9,7 @@ + Version="1.4.0.0" /> Desktop Magic