diff --git a/OpenNest/Forms/ClampsForm.cs b/OpenNest/Forms/ClampsForm.cs index 81ab5105..8c00e2d1 100644 --- a/OpenNest/Forms/ClampsForm.cs +++ b/OpenNest/Forms/ClampsForm.cs @@ -17,7 +17,7 @@ namespace OpenNest.Forms; public sealed class ClampsForm : Form { private readonly ClampSettings machineDefaults; - private readonly string settingsPath; + private readonly Action saveDefaults; private readonly NumericUpDown widthBox; private readonly NumericUpDown depthBox; private readonly NumericUpDown removalMinutesBox; @@ -30,10 +30,11 @@ public sealed class ClampsForm : Form private readonly CheckBox applyToNewNestsBox; private readonly Label savedLabel; - public ClampsForm(ClampRail rail, Units units, ClampSettings machineDefaults, string settingsPath = null) + /// Saves the machine defaults; this PC's clamp settings file when omitted. + public ClampsForm(ClampRail rail, Units units, ClampSettings machineDefaults, Action saveDefaults = null) { this.machineDefaults = machineDefaults ?? ClampSettings.Fallback; - this.settingsPath = settingsPath ?? ClampSettings.DefaultPath; + this.saveDefaults = saveDefaults ?? (settings => settings.Save(ClampSettings.DefaultPath)); Editor = new ClampRailEditor(rail, units, this.machineDefaults.Enabled); var unit = UnitsHelper.GetShortString(units); @@ -268,7 +269,7 @@ public sealed class ClampsForm : Form try { var settings = Editor.ToMachineDefaults(); - settings.Save(settingsPath); + saveDefaults(settings); savedLabel.Text = settings.Enabled ? $"Saved {DateTime.Now:t}: new nests get these {settings.ClampCount} clamps. Cancel does not undo this." : $"Saved {DateTime.Now:t}: new nests get no clamps. Cancel does not undo this."; @@ -277,7 +278,7 @@ public sealed class ClampsForm : Form { MessageBox.Show( this, - $"The machine defaults could not be saved:\n{settingsPath}\n\n{ex.Message}", + $"The machine defaults could not be saved.\n\n{ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error diff --git a/OpenNest/Forms/Commands/PostCommand.cs b/OpenNest/Forms/Commands/PostCommand.cs index e53f1e52..7413fb03 100644 --- a/OpenNest/Forms/Commands/PostCommand.cs +++ b/OpenNest/Forms/Commands/PostCommand.cs @@ -26,6 +26,10 @@ namespace OpenNest.Forms.Commands public void Run() { + // Every workstation posts with the same shared settings, re-read for each post. + if (!SharedSettings.LoadPostSettings(owner, postProcessor)) + return; + if (postProcessor is IPostProcessorNestAware nestAware) nestAware.PrepareForNest(nest); diff --git a/OpenNest/Forms/MachineConfigForm.cs b/OpenNest/Forms/MachineConfigForm.cs index 4267eeef..66f91b93 100644 --- a/OpenNest/Forms/MachineConfigForm.cs +++ b/OpenNest/Forms/MachineConfigForm.cs @@ -830,14 +830,35 @@ namespace OpenNest.Forms private void SaveAll() { - foreach (var id in _pendingDeletes) - _provider.DeleteMachine(id); - _pendingDeletes.Clear(); - - foreach (TreeNode machineNode in _tree.Nodes) + try { - if (machineNode.Tag is MachineConfig machine) - _provider.SaveMachine(machine); + foreach (var id in _pendingDeletes.ToList()) + { + _provider.DeleteMachine(id); + _pendingDeletes.Remove(id); + } + + foreach (TreeNode machineNode in _tree.Nodes) + { + if (machineNode.Tag is MachineConfig machine) + _provider.SaveMachine(machine); + } + } + catch (IOException ex) + { + // Shared machines: the server is unreachable or someone else saved first. + // Everything not yet saved stays pending. + MessageBox.Show( + this, + "The machine configurations could not all be saved.\n\n" + ex.Message + + (ex is SharedSettingSaveConflictException + ? "\n\nClose this window without saving and open it again to see their changes." + : ""), + "Save", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + return; } _dirty = false; diff --git a/OpenNest/Forms/MachineSettings.cs b/OpenNest/Forms/MachineSettings.cs index 3bebc018..cb5844dc 100644 --- a/OpenNest/Forms/MachineSettings.cs +++ b/OpenNest/Forms/MachineSettings.cs @@ -11,15 +11,26 @@ using OpenNest.IO; namespace OpenNest.Forms { /// - /// The desktop entry points for the persisted machine clamp settings and nest defaults. - /// The warning variants report an unusable file once per session. + /// The desktop entry points for the persisted machines, machine clamp settings and nest + /// defaults. In Database mode each comes from the nest server once it holds a shared copy + /// (), otherwise from this PC's files. The warning variants + /// report unusable settings once per session. /// internal static class MachineSettings { private static bool clampSettingsWarned; - /// The machine configurations edited in Tools > Machine Configuration. - public static LocalJsonProvider CreateMachineProvider() + /// + /// The machine configurations edited in Tools > Machine Configuration: the server's when + /// it holds any, otherwise this PC's. re-reads the server first. + /// + public static IDataProvider CreateMachineProvider(bool refresh = false) => + SharedSettings.Holding(SharedSettingKinds.Machine, refresh) is { } shared + ? new SharedMachineProvider(shared) + : CreateLocalMachineProvider(); + + /// This PC's machine configurations, under %APPDATA%\OpenNest\Machines. + private static LocalJsonProvider CreateLocalMachineProvider() { var provider = new LocalJsonProvider(Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @@ -30,12 +41,17 @@ namespace OpenNest.Forms } /// Every readable machine configuration, for choosing acceleration limits. - public static List LoadMachines() + public static List LoadMachines() => ReadMachines(() => CreateMachineProvider()); + + /// Every readable machine configuration on this PC, shared or not, for uploading. + public static List LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider); + + private static List ReadMachines(Func createProvider) { var machines = new List(); try { - var provider = CreateMachineProvider(); + var provider = createProvider(); foreach (var summary in provider.GetMachines()) { if (provider.GetMachine(summary.Id) is { } machine) @@ -70,10 +86,11 @@ namespace OpenNest.Forms var problem = status == ClampSettingsStatus.Invalid ? "could not be read, so new nests have no clamps" - : "has invalid values that were ignored, so new nests may be missing clamps"; + : "have invalid values that were ignored, so new nests may be missing clamps"; + var source = SharedClamps() != null ? "on the nest server" : ClampSettings.DefaultPath; MessageBox.Show( owner, - $"The clamp settings file {problem}:\n{ClampSettings.DefaultPath}\n\nCheck the clamps in Nest > Clamps and use Save as Machine Defaults to rewrite the file.", + $"The clamp settings {problem}:\n{source}\n\nCheck the clamps in Nest > Clamps and use Save as Machine Defaults to rewrite them.", "Clamps", MessageBoxButtons.OK, MessageBoxIcon.Warning @@ -174,7 +191,26 @@ namespace OpenNest.Forms /// The single desktop entry point for the persisted machine clamp settings. public static ClampSettings LoadSavedClampSettings(out ClampSettingsStatus status) => - ClampSettings.Load(ClampSettings.DefaultPath, out status); + SharedClamps() is { } shared + ? ClampSettings.Parse(shared.Json, out status) + : ClampSettings.Load(ClampSettings.DefaultPath, out status); + + /// + /// Saves the clamp settings new nests start from where they are read: the server once it + /// holds them, otherwise this PC's file. Throws when the server + /// cannot take them. + /// + public static void SaveClampSettings(ClampSettings settings) + { + if (SharedSettings.Current() is { } current + && current.Get(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) != null) + current.Save(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey, settings.ToJson()); + else + settings.Save(ClampSettings.DefaultPath); + } + + private static SharedSettingRecord SharedClamps() => + SharedSettings.Find(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey); /// /// Loads the persisted nest defaults (see ); @@ -187,8 +223,9 @@ namespace OpenNest.Forms if (status == NestDefaultsStatus.Invalid && !defaultsWarned) { defaultsWarned = true; + var source = SharedNestDefaults() != null ? "on the nest server" : NestDefaults.DefaultPath; MessageBox.Show( - $"The nest defaults file could not be read:\n{NestDefaults.DefaultPath}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.", + $"The nest defaults could not be read:\n{source}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.", "Nest Defaults", MessageBoxButtons.OK, MessageBoxIcon.Warning @@ -204,10 +241,28 @@ namespace OpenNest.Forms /// the defaults dialog always agree. /// public static NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) => - NestDefaults.Load( - NestDefaults.DefaultPath, - Properties.Settings.Default.DefaultUnit, - out status - ); + SharedNestDefaults() is { } shared + ? NestDefaults.Parse(shared.Json, Properties.Settings.Default.DefaultUnit, out status) + : NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out status); + + /// This PC's own nest defaults, shared or not, for uploading. + public static NestDefaults LoadLocalNestDefaults() => + NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out _); + + /// + /// Saves the nest defaults where they are read: the server once it holds them, otherwise + /// this PC's file. Throws when the server cannot take them. + /// + public static void SaveNestDefaults(NestDefaults defaults) + { + if (SharedSettings.Current() is { } current + && current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) != null) + current.Save(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, defaults.ToJson()); + else + defaults.Save(NestDefaults.DefaultPath); + } + + private static SharedSettingRecord SharedNestDefaults() => + SharedSettings.Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey); } } diff --git a/OpenNest/Forms/MainForm.Nest.cs b/OpenNest/Forms/MainForm.Nest.cs index 6de50e37..c5c4d410 100644 --- a/OpenNest/Forms/MainForm.Nest.cs +++ b/OpenNest/Forms/MainForm.Nest.cs @@ -139,7 +139,7 @@ namespace OpenNest.Forms } var defaults = MachineSettings.LoadSavedClampSettings(out _); - using var form = new ClampsForm(nest.PlateDefaults.ClampRail, nest.Units, defaults); + using var form = new ClampsForm(nest.PlateDefaults.ClampRail, nest.Units, defaults, MachineSettings.SaveClampSettings); if (form.ShowDialog(this) != DialogResult.OK) return; diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs index 090e5bb8..e322797e 100644 --- a/OpenNest/Forms/MainForm.Tools.cs +++ b/OpenNest/Forms/MainForm.Tools.cs @@ -175,7 +175,20 @@ namespace OpenNest.Forms using (var form = new NestDefaultsForm(MachineSettings.LoadSavedNestDefaults(out _), MachineSettings.LoadMachines())) { if (form.ShowDialog(this) == DialogResult.OK) - form.GetDefaults().Save(NestDefaults.DefaultPath); + SaveNestDefaults(form.GetDefaults()); + } + } + + private void SaveNestDefaults(NestDefaults defaults) + { + try + { + MachineSettings.SaveNestDefaults(defaults); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + MessageBox.Show(this, "The nest defaults could not be saved.\n\n" + ex.Message, "Nest Defaults", + MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -187,14 +200,18 @@ namespace OpenNest.Forms var defaults = NestDefaults.FromPlate(activeForm.Nest.Units, activeForm.PlateView.Plate); // The nest's machine becomes the default; a nest without one keeps the saved choice. defaults.MachineId = activeForm.Nest.Machine?.Id ?? MachineSettings.LoadSavedNestDefaults(out _).MachineId; - defaults.Save(NestDefaults.DefaultPath); + SaveNestDefaults(defaults); } private void MachineConfig_Click(object sender, EventArgs e) { - var provider = MachineSettings.CreateMachineProvider(); + var provider = MachineSettings.CreateMachineProvider(refresh: true); + SharedSettings.WarnIfOffline(this); using (var form = new MachineConfigForm(provider)) { + var source = SharedSettings.MachineSource(provider); + if (source.Length > 0) + form.Text += $" ({source})"; form.ShowDialog(this); } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 89131834..a01db659 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -211,6 +211,9 @@ namespace OpenNest.Forms mnuNestPost.Visible = mnuNestPost.DropDownItems.Count > 0; postButton.Visible = postButton.DropDownItems.Count > 0; + // Database mode: offer this PC's settings to a server that has none yet. + Shown += (_, _) => SharedSettings.OfferUpload(this, posts.PostProcessors); + if (posts.Failures.Count > 0) Shown += (_, _) => MessageBox.Show(this, "These post processors could not be loaded:\n\n" + string.Join("\n", posts.Failures), diff --git a/OpenNest/Forms/PostProcessorConfigForm.cs b/OpenNest/Forms/PostProcessorConfigForm.cs index c87044f1..3c92bc1c 100644 --- a/OpenNest/Forms/PostProcessorConfigForm.cs +++ b/OpenNest/Forms/PostProcessorConfigForm.cs @@ -1,7 +1,9 @@ using System; +using System.IO; using System.Text.Json; using System.Windows.Forms; using OpenNest.Controls; +using OpenNest.Data; using OpenNest.PostSettings; namespace OpenNest.Forms @@ -10,6 +12,7 @@ namespace OpenNest.Forms { private readonly IConfigurablePostProcessor postProcessor; private readonly string configBackup; + private readonly string configBefore; private readonly PostSettingsEditor settingsEditor; public PostProcessorConfigForm(IConfigurablePostProcessor postProcessor) @@ -18,6 +21,7 @@ namespace OpenNest.Forms this.postProcessor = postProcessor; this.Text = postProcessor.Name + " Settings"; + configBefore = (postProcessor as IJsonConfigPostProcessor)?.SerializeConfig(); var sections = PostSettingsLayout.TryBuild(postProcessor.Config.GetType()); if (sections != null) @@ -56,14 +60,36 @@ namespace OpenNest.Forms return; } - postProcessor.SaveConfig(); + try + { + SharedSettings.SavePostSettings(postProcessor, configBefore); + } + catch (SharedSettingSaveConflictException conflict) + { + // Post with the settings the other workstation saved, never a mix of both. + ((IJsonConfigPostProcessor)postProcessor).LoadConfig(conflict.Current.Json); + DialogResult = DialogResult.Cancel; + MessageBox.Show(this, conflict.Message + "\n\nTheir settings are now loaded and nothing was posted. " + + "Post again to review them and make your change.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + DialogResult = DialogResult.None; + MessageBox.Show(this, "The post settings could not be saved.\n\n" + ex.Message + + "\n\nCancel to post nothing and discard the changes.", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } } private void cancelButton_Click(object sender, EventArgs e) { - // The sectioned editor never wrote to the config. + // The sectioned editor writes to the config only on OK; an OK whose save failed is undone here. if (configBackup == null) + { + if (configBefore != null && postProcessor is IJsonConfigPostProcessor json + && json.SerializeConfig() != configBefore) + json.LoadConfig(configBefore); return; + } // Restore config from backup var original = JsonSerializer.Deserialize(configBackup, postProcessor.Config.GetType()); diff --git a/OpenNest/Forms/SharedSettings.cs b/OpenNest/Forms/SharedSettings.cs new file mode 100644 index 00000000..726e1fc4 --- /dev/null +++ b/OpenNest/Forms/SharedSettings.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Windows.Forms; +using OpenNest.Data; + +namespace OpenNest.Forms +{ + /// + /// The settings every workstation shares through the nest server in Database mode: machines, + /// post settings, clamp settings and nest defaults (see docs/shared-settings.md). Each kind + /// is read from the server once it holds a copy and from this PC's own files until then. + /// The server's copy is re-read when it is older than a few minutes, and always before + /// posting or editing machines; while the server is unreachable the last copy read is used + /// and nothing shared can be changed. + /// + internal static class SharedSettings + { + private static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(2); + private static readonly TimeSpan OfflineRetry = TimeSpan.FromMinutes(1); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private static readonly object sync = new(); + private static SharedSettingsStore store; + private static string storeUrl; + private static bool offlineWarned; + + /// + /// The shared settings for the configured server, re-read from it when + /// is set or the copy is stale; null in File mode or when the + /// server predates shared settings. + /// + public static SharedSettingsStore Current(bool refresh = false) + { + var settings = NestStorage.Settings; + if (!settings.IsDatabaseMode) + return null; + + lock (sync) + { + if (store == null || storeUrl != settings.ServerUrl) + { + store?.Dispose(); + store = null; + try + { + store = new SharedSettingsStore( + new RemoteSharedSettings(settings.ServerUrl, Timeout), + CachePath(settings.ServerUrl), + Environment.UserName); + } + catch (ArgumentException) + { + return null; + } + storeUrl = settings.ServerUrl; + } + + var age = DateTime.Now - (store.RefreshedAt ?? DateTime.MinValue); + if (refresh || age > (store.IsOffline ? OfflineRetry : MaxAge)) + store.Refresh(); + return store.IsUnsupported ? null : store; + } + } + + /// The shared document of under , or null when this PC's own is used. + public static SharedSettingRecord Find(string kind, string key, bool refresh = false) => + Current(refresh)?.Get(kind, key); + + /// The store when the server holds documents of , so they replace this PC's own. + public static SharedSettingsStore Holding(string kind, bool refresh = false) => + Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null; + + /// Where this PC keeps its last copy of a server's shared settings. + private static string CachePath(string serverUrl) + { + var key = (serverUrl ?? "").Trim().TrimEnd('/').ToLowerInvariant(); + var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..16]; + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", + "SharedSettings", + name + ".json"); + } + + /// + /// Tells the user once per session that the server could not be reached and the last + /// copy read is in use. + /// + public static void WarnIfOffline(IWin32Window owner) + { + var current = store; + if (current is not { IsOffline: true } || offlineWarned) + return; + offlineWarned = true; + var copy = current.ReadAt is { } readAt + ? $"the copy read {readAt:g}" + : "this PC's own settings (no copy of the shared ones has been read yet)"; + MessageBox.Show(owner, + $"The nest server could not be reached, so machines, post settings, clamps and nest defaults come from {copy}. " + + "They cannot be changed until the server is back.\n\n" + current.LastError?.Message, + "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + /// Where Tools > Machine Configuration reads and saves, for its title. + public static string MachineSource(IDataProvider provider) + { + if (provider is not SharedMachineProvider) + return Current() == null ? "" : "this PC only: not shared on the server yet"; + return store?.IsOffline == true ? "shared, read-only: the server cannot be reached" : "shared on the nest server"; + } + + /// The key of a post's shared settings. + public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName; + + /// + /// Gives the server's settings, re-read for this post, when + /// the server holds them. Returns false (after telling the user) when they are unusable. + /// + public static bool LoadPostSettings(IWin32Window owner, IPostProcessor postProcessor) + { + if (postProcessor is not IJsonConfigPostProcessor post) + return true; + var record = Find(SharedSettingKinds.Post, PostKey(post), refresh: true); + WarnIfOffline(owner); + if (record == null) + return true; + try + { + post.LoadConfig(record.Json); + return true; + } + catch (System.Text.Json.JsonException ex) + { + MessageBox.Show(owner, + $"The shared {post.Name} settings on the nest server could not be read, so nothing was posted.\n\n{ex.Message}", + "Post Settings", MessageBoxButtons.OK, MessageBoxIcon.Error); + return false; + } + } + + /// + /// Saves a post's settings where they are read: the server once it holds them, otherwise + /// the post's own file. JSON equal to (the settings before the + /// dialog opened) is not saved, so posting without editing never writes a shared revision. + /// Throws when the server cannot take them. + /// + public static void SavePostSettings(IConfigurablePostProcessor postProcessor, string unchanged) + { + if (postProcessor is not IJsonConfigPostProcessor post) + { + postProcessor.SaveConfig(); + return; + } + var json = post.SerializeConfig(); + if (json == unchanged) + return; + if (Current() is { } current && current.Get(SharedSettingKinds.Post, PostKey(post)) != null) + current.Save(SharedSettingKinds.Post, PostKey(post), json); + else + post.SaveConfig(); + } + + /// + /// When the server holds none of this PC's machines, post settings, clamp settings or nest + /// defaults, offers to upload them so every workstation uses them. Documents the server + /// already holds are never replaced. + /// + public static void OfferUpload(IWin32Window owner, IEnumerable postProcessors) + { + var current = Current(refresh: true); + WarnIfOffline(owner); + if (current == null || current.IsOffline) + return; + + var uploads = new List<(string Kind, string Key, string Json, string Label)>(); + if (current.List(SharedSettingKinds.Machine).Count == 0) + { + foreach (var machine in MachineSettings.LoadLocalMachines()) + uploads.Add((SharedSettingKinds.Machine, machine.Id.ToString("D"), SharedMachineProvider.Serialize(machine), + $"Machine: {machine.Name}")); + } + foreach (var post in postProcessors.OfType()) + { + if (current.Get(SharedSettingKinds.Post, PostKey(post)) == null) + uploads.Add((SharedSettingKinds.Post, PostKey(post), post.SerializeConfig(), $"Post settings: {post.Name}")); + } + if (current.Get(SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey) == null + && File.Exists(ClampSettings.DefaultPath)) + uploads.Add((SharedSettingKinds.Clamps, SharedSettingKinds.DefaultKey, + ClampSettings.Load(ClampSettings.DefaultPath).ToJson(), "Clamp settings")); + if (current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null + && File.Exists(NestDefaults.DefaultPath)) + uploads.Add((SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, + MachineSettings.LoadLocalNestDefaults().ToJson(), "Nest defaults")); + if (uploads.Count == 0) + return; + + var answer = MessageBox.Show(owner, + "The nest server does not have these settings yet:\n\n" + + string.Join("\n", uploads.Select(u => "• " + u.Label)) + + "\n\nUpload this PC's copies so every workstation nests and posts with them? " + + "Until then, each PC keeps using its own.", + "Shared Settings", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (answer != DialogResult.Yes) + return; + + var failures = new List(); + foreach (var upload in uploads) + { + try + { + current.Save(upload.Kind, upload.Key, upload.Json); + } + catch (IOException ex) + { + failures.Add($"{upload.Label}: {ex.Message}"); + } + } + if (failures.Count > 0) + MessageBox.Show(owner, "Some settings were not uploaded:\n\n" + string.Join("\n", failures), + "Shared Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } +}