using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using OpenNest.Data; namespace OpenNest.Desktop; /// A document this PC could upload to a nest server that does not hold it yet. public sealed record SharedSettingUpload(string Kind, string Key, string Json, string Label); /// /// 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. Front ends show the texts it returns. /// public sealed class SharedSettingsSource { 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 readonly object sync = new(); private readonly Func storageSettings; private readonly Func createStore; private readonly Func now; private SharedSettingsStore store; private string storeUrl; private bool offlineWarned; /// The current storage mode and server, read on every use. /// Makes the store for a server URL; defaults to . /// The clock deciding when the copy is stale; defaults to . public SharedSettingsSource( Func storageSettings, Func createStore = null, Func now = null) { this.storageSettings = storageSettings ?? throw new ArgumentNullException(nameof(storageSettings)); this.createStore = createStore ?? CreateStore; this.now = now ?? (() => DateTime.Now); } /// /// A source reading the storage mode once from %APPDATA%\OpenNest\storage.json, for a front /// end that does not change it while running. /// public static SharedSettingsSource FromStorageFile(string path = null) { var settings = NestStorageSettings.Load(path ?? NestStorageSettings.DefaultPath); return new SharedSettingsSource(() => settings); } /// /// The store for : requests give up after five seconds and the /// last copy read is kept under %APPDATA%\OpenNest\SharedSettings. Throws /// for an invalid URL. /// public static SharedSettingsStore CreateStore(string serverUrl) => new(new RemoteSharedSettings(serverUrl, Timeout), CachePath(serverUrl), Environment.UserName); /// Where this PC keeps its last copy of a server's shared settings. public 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"); } /// /// 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 SharedSettingsStore Current(bool refresh = false) { var settings = storageSettings(); if (settings == null || !settings.IsDatabaseMode) return null; lock (sync) { if (store == null || storeUrl != settings.ServerUrl) { store?.Dispose(); store = null; try { store = createStore(settings.ServerUrl); } catch (ArgumentException) { return null; } storeUrl = settings.ServerUrl; } var age = 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 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 SharedSettingsStore Holding(string kind, bool refresh = false) => Current(refresh) is { } current && current.List(kind).Count > 0 ? current : null; /// Whether the last read of the server failed, so the last copy read is in use. public bool IsOffline => store?.IsOffline == true; /// /// What to tell the user, once per session, when the server could not be reached and the /// last copy read is in use; null when there is nothing (more) to say. /// public string TakeOfflineWarning() { var current = store; if (current is not { IsOffline: true } || offlineWarned) return null; 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)"; return $"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; } /// Where Tools > Machine Configuration reads and saves, for its title. public 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"; } /// /// Where Tools > Nest Defaults saves (the server once it holds them) and whether it can save there now. /// public (string Text, bool ReadOnly) NestDefaultsSource() { if (Find(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null) return (Current() == null ? "Saved on this PC." : "Saved on this PC only: not shared on the server yet.", false); return store?.IsOffline == true ? ("Shared, read-only: the server cannot be reached.", true) : ("Shared with every workstation on the nest server.", false); } /// The key of a post's shared settings. public static string PostKey(IPostProcessor postProcessor) => postProcessor.GetType().FullName; /// /// Gives a post that shares its settings the server's, re-read for this post, when the /// server holds them. Returns why they are unusable (nothing may be posted), or null. /// public string LoadPostSettings(IJsonConfigPostProcessor post) { var record = Find(SharedSettingKinds.Post, PostKey(post), refresh: true); if (record == null) return null; try { post.LoadConfig(record.Json); return null; } catch (System.Text.Json.JsonException ex) { return $"The shared {post.Name} settings on the nest server could not be read, so nothing was posted.\n\n{ex.Message}"; } } /// /// 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 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(); } /// /// This PC's machines (with their clamps; only while the server holds none), post settings /// and nest defaults (when gives them) that /// does not hold yet. Documents the server holds are never listed. /// public static IReadOnlyList PendingUploads( SharedSettingsStore current, Func> localMachines, IEnumerable postProcessors, Func localDefaultsJson) { var uploads = new List(); if (current.List(SharedSettingKinds.Machine).Count == 0) { foreach (var machine in localMachines()) uploads.Add(new(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(new(SharedSettingKinds.Post, PostKey(post), post.SerializeConfig(), $"Post settings: {post.Name}")); } if (current.Get(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey) == null && localDefaultsJson() is { } defaults) uploads.Add(new(SharedSettingKinds.NestDefaults, SharedSettingKinds.DefaultKey, defaults, "Nest defaults")); return uploads; } /// The question offering to the server. public static string DescribeUploads(IEnumerable uploads) => "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."; /// /// Saves each upload to ; returns what to tell the user about the /// ones that failed, or null when all were uploaded. /// public static string Upload(SharedSettingsStore current, IEnumerable uploads) { 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}"); } } return failures.Count > 0 ? "Some settings were not uploaded:\n\n" + string.Join("\n", failures) : null; } }