Fix plugin settings sync when multiple monitors use same layout

This commit is contained in:
Stone_Red
2026-08-03 00:56:04 +02:00
parent c48eb532f2
commit af2666f9a6
8 changed files with 319 additions and 12 deletions
@@ -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<Bitmap?> MainAsync(CancellationToken cancellationToken)
+26
View File
@@ -291,6 +291,32 @@ public sealed class Manager
SaveSettings();
}
private readonly Dictionary<(Layout, uint), SettingSynchronizer> settingSynchronizers = [];
/// <summary>
/// Gets (or creates) the synchronizer that keeps the settings of all windows showing the
/// given plugin in the given layout in sync.
/// </summary>
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;
}
/// <summary>
/// Drops the synchronizer for the given plugin in the given layout once no windows use it anymore.
/// </summary>
internal void ReleaseSettingSynchronizer(Layout layout, uint pluginId)
{
_ = settingSynchronizers.Remove((layout, pluginId));
}
#endregion
#region Settings Management
@@ -21,5 +21,8 @@ public interface IPluginWindow
void Hide();
void Close();
void ApplySettingValue(string id, string value);
void ApplyButtonClick(string id);
event System.ComponentModel.CancelEventHandler Closing;
}
+94 -7
View File
@@ -9,6 +9,7 @@ using DesktopMagic.Settings;
using SkiaSharp;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
@@ -44,6 +45,7 @@ public partial class PluginWindow : Window, IPluginWindow
private readonly Rectangle screenBounds;
private readonly string screenDeviceName;
private bool isUpdatingPosition = false;
private bool _editMode = false;
private CancellationTokenSource? pluginCancellationTokenSource;
private FileSystemWatcher? pluginFileWatcher;
@@ -58,8 +60,13 @@ public partial class PluginWindow : Window, IPluginWindow
private readonly PropertyChangedEventHandler themePropertyChangedHandler;
private Theme? subscribedTheme;
private NotifyCollectionChangedEventHandler? themesCollectionChangedHandler;
private readonly List<Setting> subscribedSettings = [];
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<string, Setting> localSettings = [];
private SettingSynchronizer? synchronizer;
private bool suppressButtonSync = false;
public bool IsRunning { get; private set; } = true;
public PluginMetadata PluginMetadata { get; private set; }
@@ -121,6 +128,12 @@ public partial class PluginWindow : Window, IPluginWindow
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
@@ -346,6 +359,8 @@ public partial class PluginWindow : Window, IPluginWindow
public void SetEditMode(bool enabled)
{
_editMode = enabled;
if (enabled)
{
Topmost = true;
@@ -645,9 +660,16 @@ public partial class PluginWindow : Window, IPluginWindow
}
void SetWindowLayer()
{
if (_editMode)
{
Topmost = true;
}
else
{
WindowPos.SetWindowLayer(this, pluginClassInstance.windowLayer.Value);
}
}
void SetRotation()
{
@@ -686,7 +708,7 @@ public partial class PluginWindow : Window, IPluginWindow
defaultSettingsSubscriptions.Add((setting, handler));
}
private void OnPluginSettingValueChanged()
private void OnPluginSettingValueChanged(Setting setting, string id)
{
pluginClassInstance?.OnSettingsChanged();
@@ -694,6 +716,34 @@ public partial class PluginWindow : Window, IPluginWindow
{
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)
@@ -713,16 +763,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 += OnPluginSettingValueChanged;
subscribedSettings.Add(element);
Action valueChangedHandler = () => OnPluginSettingValueChanged(element, elementAttribute.Id);
element.OnValueChanged += valueChangedHandler;
subscribedSettings.Add((element, valueChangedHandler));
if (element is Button button)
{
Action clickHandler = () =>
{
if (!suppressButtonSync)
{
synchronizer?.ButtonClicked(this, elementAttribute.Id);
}
};
button.OnClick += clickHandler;
subscribedButtonClicks.Add((button, clickHandler));
}
settingElements.Add(settingElement);
break;
@@ -1070,6 +1140,15 @@ public partial class PluginWindow : Window, IPluginWindow
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()
@@ -1098,9 +1177,9 @@ public partial class PluginWindow : Window, IPluginWindow
themesCollectionChangedHandler = null;
}
foreach (Setting setting in subscribedSettings)
foreach ((Setting Setting, Action Handler) subscription in subscribedSettings)
{
setting.OnValueChanged -= OnPluginSettingValueChanged;
subscription.Setting.OnValueChanged -= subscription.Handler;
}
subscribedSettings.Clear();
@@ -1109,6 +1188,14 @@ public partial class PluginWindow : Window, IPluginWindow
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()
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Threading;
namespace DesktopMagic.Plugins;
/// <summary>
/// Keeps the per-instance plugin settings of every window sharing the same layout in sync.
/// Each window owns its own <see cref="DesktopMagic.Api.Settings.Setting"/> 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.
/// </summary>
internal sealed class SettingSynchronizer
{
private readonly object _lock = new();
private readonly List<IPluginWindow> windows = [];
private readonly HashSet<(IPluginWindow Window, string Id)> inFlight = [];
/// <summary>
/// Registers a window so it receives mirrored settings from the other windows of this layout.
/// </summary>
public void Register(IPluginWindow window)
{
lock (_lock)
{
if (!windows.Contains(window))
{
windows.Add(window);
}
}
}
/// <summary>
/// Unregisters a window and returns whether no windows remain.
/// </summary>
public bool Unregister(IPluginWindow window)
{
lock (_lock)
{
windows.Remove(window);
return windows.Count == 0;
}
}
/// <summary>
/// Mirrors a setting value change onto every other registered window.
/// </summary>
public void SettingChanged(IPluginWindow origin, string id, string value)
{
Mirror(origin, id, window => window.ApplySettingValue(id, value));
}
/// <summary>
/// Mirrors a button click onto every other registered window.
/// </summary>
public void ButtonClicked(IPluginWindow origin, string id)
{
Mirror(origin, id, window => window.ApplyButtonClick(id));
}
private void Mirror(IPluginWindow origin, string id, Action<IPluginWindow> 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));
}
}
});
}
}
}
@@ -6,6 +6,7 @@ using DesktopMagic.Settings;
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
@@ -41,6 +42,10 @@ public partial class WebPluginWindow : Window, IPluginWindow
private readonly List<(Setting Setting, Action Handler)> subscribedSettings = [];
private readonly List<(Button Button, Action Handler)> subscribedButtonClicks = [];
private readonly ConcurrentDictionary<string, Setting> 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; }
@@ -101,6 +106,12 @@ public partial class WebPluginWindow : Window, IPluginWindow
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();
@@ -157,6 +168,32 @@ 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;
}
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
@@ -290,6 +327,15 @@ public partial class WebPluginWindow : Window, IPluginWindow
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
{
if (isInitialized && webView.CoreWebView2 != null)
@@ -324,6 +370,8 @@ public partial class WebPluginWindow : Window, IPluginWindow
button.OnClick -= handler;
}
subscribedButtonClicks.Clear();
localSettings.Clear();
}
private void UpdatePosition()
@@ -450,12 +498,18 @@ 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;
@@ -463,6 +517,11 @@ public partial class WebPluginWindow : Window, IPluginWindow
{
Action clickHandler = () =>
{
if (!suppressButtonSync)
{
synchronizer?.ButtonClicked(this, capturedId);
}
_ = webView.Dispatcher.InvokeAsync(async () =>
{
try
@@ -481,6 +540,8 @@ public partial class WebPluginWindow : Window, IPluginWindow
Action valueChangedHandler = () =>
{
synchronizer?.SettingChanged(this, capturedId, setting.GetJsonValue());
_ = webView.Dispatcher.InvokeAsync(async () =>
{
try
@@ -46,4 +46,14 @@ public class Button : Setting
{
OnClick?.Invoke();
}
internal override string GetJsonValue()
{
return Value;
}
internal override void SetJsonValue(string value)
{
Value = value;
}
}
@@ -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;
}
}