feat(desktop): use the server's shared settings in Database mode

Machines, post settings, clamp defaults and nest defaults come from the
nest server once it holds them, and from this PC's files until then; saves
go where the settings were read. The server's copy is re-read when older
than two minutes, before every post and when Machine Configuration opens.
While the server is unreachable the last copy read is used, the user is told
once, and nothing shared can be changed.

Posting re-reads the post's shared settings; saving them is skipped when the
dialog changed nothing, and a conflict loads the other workstation's
settings and stops the post. On first start the desktop offers to upload the
settings the server lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 20:02:22 -04:00
1 parent 90e2fd2e6e
commit 18612e698d
9 files changed
+387 -33

No files matched your search

+6 -5
View File
@@ -17,7 +17,7 @@ namespace OpenNest.Forms;
public sealed class ClampsForm : Form
{
private readonly ClampSettings machineDefaults;
private readonly string settingsPath;
private readonly Action<ClampSettings> 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)
/// <param name="saveDefaults">Saves the machine defaults; this PC's clamp settings file when omitted.</param>
public ClampsForm(ClampRail rail, Units units, ClampSettings machineDefaults, Action<ClampSettings> 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
+4
View File
@@ -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);
+28 -7
View File
@@ -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;
+70 -15
View File
@@ -11,15 +11,26 @@ using OpenNest.IO;
namespace OpenNest.Forms
{
/// <summary>
/// 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
/// (<see cref="SharedSettings"/>), otherwise from this PC's files. The warning variants
/// report unusable settings once per session.
/// </summary>
internal static class MachineSettings
{
private static bool clampSettingsWarned;
/// <summary>The machine configurations edited in Tools > Machine Configuration.</summary>
public static LocalJsonProvider CreateMachineProvider()
/// <summary>
/// The machine configurations edited in Tools > Machine Configuration: the server's when
/// it holds any, otherwise this PC's. <paramref name="refresh"/> re-reads the server first.
/// </summary>
public static IDataProvider CreateMachineProvider(bool refresh = false) =>
SharedSettings.Holding(SharedSettingKinds.Machine, refresh) is { } shared
? new SharedMachineProvider(shared)
: CreateLocalMachineProvider();
/// <summary>This PC's machine configurations, under %APPDATA%\OpenNest\Machines.</summary>
private static LocalJsonProvider CreateLocalMachineProvider()
{
var provider = new LocalJsonProvider(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -30,12 +41,17 @@ namespace OpenNest.Forms
}
/// <summary>Every readable machine configuration, for choosing acceleration limits.</summary>
public static List<MachineConfig> LoadMachines()
public static List<MachineConfig> LoadMachines() => ReadMachines(() => CreateMachineProvider());
/// <summary>Every readable machine configuration on this PC, shared or not, for uploading.</summary>
public static List<MachineConfig> LoadLocalMachines() => ReadMachines(CreateLocalMachineProvider);
private static List<MachineConfig> ReadMachines(Func<IDataProvider> createProvider)
{
var machines = new List<MachineConfig>();
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
/// <summary>The single desktop entry point for the persisted machine clamp settings.</summary>
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);
/// <summary>
/// Saves the clamp settings new nests start from where they are read: the server once it
/// holds them, otherwise this PC's file. Throws <see cref="IOException"/> when the server
/// cannot take them.
/// </summary>
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);
/// <summary>
/// Loads the persisted nest defaults (see <see cref="LoadSavedNestDefaults"/>);
@@ -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.
/// </summary>
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);
/// <summary>This PC's own nest defaults, shared or not, for uploading.</summary>
public static NestDefaults LoadLocalNestDefaults() =>
NestDefaults.Load(NestDefaults.DefaultPath, Properties.Settings.Default.DefaultUnit, out _);
/// <summary>
/// Saves the nest defaults where they are read: the server once it holds them, otherwise
/// this PC's file. Throws <see cref="IOException"/> when the server cannot take them.
/// </summary>
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);
}
}
+1 -1
View File
@@ -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;
+20 -3
View File
@@ -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);
}
}
+3
View File
@@ -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),
+28 -2
View File
@@ -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());
+227
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// The shared settings for the configured server, re-read from it when
/// <paramref name="refresh"/> is set or the copy is stale; null in File mode or when the
/// server predates shared settings.
/// </summary>
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;
}
}
/// <summary>The shared document of <paramref name="kind"/> under <paramref name="key"/>, or null when this PC's own is used.</summary>
public static SharedSettingRecord Find(string kind, string key, bool refresh = false) =>
Current(refresh)?.Get(kind, key);
/// <summary>The store when the server holds documents of <paramref name="kind"/>, so they replace this PC's own.</summary>
public static SharedSettingsStore Holding(string kind, bool refresh = false) =>
Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null;
/// <summary>Where this PC keeps its last copy of a server's shared settings.</summary>
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");
}
/// <summary>
/// Tells the user once per session that the server could not be reached and the last
/// copy read is in use.
/// </summary>
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);
}
/// <summary>Where Tools > Machine Configuration reads and saves, for its title.</summary>
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";
}
/// <summary>The key of a post's shared settings.</summary>
public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName;
/// <summary>
/// Gives <paramref name="postProcessor"/> the server's settings, re-read for this post, when
/// the server holds them. Returns false (after telling the user) when they are unusable.
/// </summary>
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;
}
}
/// <summary>
/// Saves a post's settings where they are read: the server once it holds them, otherwise
/// the post's own file. JSON equal to <paramref name="unchanged"/> (the settings before the
/// dialog opened) is not saved, so posting without editing never writes a shared revision.
/// Throws <see cref="IOException"/> when the server cannot take them.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public static void OfferUpload(IWin32Window owner, IEnumerable<IPostProcessor> 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<IJsonConfigPostProcessor>())
{
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<string>();
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);
}
}
}