diff --git a/Source/NETworkManager.Profiles/ProfileManager.cs b/Source/NETworkManager.Profiles/ProfileManager.cs
index 19784d812b..87ad34f87d 100644
--- a/Source/NETworkManager.Profiles/ProfileManager.cs
+++ b/Source/NETworkManager.Profiles/ProfileManager.cs
@@ -1,5 +1,4 @@
using log4net;
-using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
using System;
@@ -9,6 +8,8 @@
using System.Linq;
using System.Security;
using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
using System.Xml.Serialization;
namespace NETworkManager.Profiles;
@@ -28,16 +29,43 @@ public static class ProfileManager
///
private const string ProfilesDefaultFileName = "Default";
+ ///
+ /// Settings backups directory name.
+ ///
+ private static string BackupFolderName => "Backups";
+
///
/// Profile file extension.
///
- private const string ProfileFileExtension = ".xml";
+ private const string ProfileFileExtension = ".json";
+
+ ///
+ /// Legacy XML profile file extension.
+ ///
+ [Obsolete("Legacy XML profiles are no longer used, but the extension is kept for migration purposes.")]
+ private static string LegacyProfileFileExtension => ".xml";
///
/// Profile file extension for encrypted files.
///
private const string ProfileFileExtensionEncrypted = ".encrypted";
+ ///
+ /// JSON serializer options for consistent serialization/deserialization.
+ ///
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ ///
+ /// Maximum number of bytes to check for XML content detection.
+ ///
+ private const int XmlDetectionBufferSize = 200;
+
///
/// ObservableCollection of all profile files.
///
@@ -106,6 +134,36 @@ private static void LoadedProfileFileChanged(ProfileFileInfo profileFileInfo, bo
OnLoadedProfileFileChangedEvent?.Invoke(null, new ProfileFileInfoArgs(profileFileInfo, profileFileUpdating));
}
+ ///
+ /// Occurs when the profile migration process begins.
+ ///
+ [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")]
+ public static event EventHandler OnProfileMigrationStarted;
+
+ ///
+ /// Raises the event indicating that the profile migration process from legacy XML files has started.
+ ///
+ [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")]
+ private static void ProfileMigrationStarted()
+ {
+ OnProfileMigrationStarted?.Invoke(null, EventArgs.Empty);
+ }
+
+ ///
+ /// Occurs when the profile migration from legacy XML files has completed.
+ ///
+ [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")]
+ public static event EventHandler OnProfileMigrationCompleted;
+
+ ///
+ /// Raises the event indicating that the profile migration from legacy XML files has completed.
+ ///
+ [Obsolete("Will be removed after some time, as profile migration from legacy XML files is a one-time process.")]
+ private static void ProfileMigrationCompleted()
+ {
+ OnProfileMigrationCompleted?.Invoke(null, EventArgs.Empty);
+ }
+
///
/// Event is fired if the profiles have changed.
///
@@ -137,6 +195,15 @@ public static string GetProfilesFolderLocation()
AssemblyManager.Current.Name, ProfilesFolderName);
}
+ ///
+ /// Method to get the path of the profiles backup folder.
+ ///
+ /// Path to the profiles backup folder.
+ public static string GetSettingsBackupFolderLocation()
+ {
+ return Path.Combine(GetProfilesFolderLocation(), BackupFolderName);
+ }
+
///
/// Method to get the default profile file name.
///
@@ -168,7 +235,9 @@ private static string GetProfilesDefaultFilePath()
private static IEnumerable GetProfileFiles(string location)
{
return Directory.GetFiles(location).Where(x =>
- Path.GetExtension(x) == ProfileFileExtension || Path.GetExtension(x) == ProfileFileExtensionEncrypted);
+ Path.GetExtension(x) == ProfileFileExtension ||
+ Path.GetExtension(x) == ProfileFileExtensionEncrypted ||
+ Path.GetExtension(x) == LegacyProfileFileExtension);
}
///
@@ -180,10 +249,14 @@ private static void LoadProfileFiles()
// Folder exists
if (Directory.Exists(location))
+ {
foreach (var file in GetProfileFiles(location))
+ {
// Gets the filename, path and if the file is encrypted.
ProfileFiles.Add(new ProfileFileInfo(Path.GetFileNameWithoutExtension(file), file,
Path.GetFileName(file).EndsWith(ProfileFileExtensionEncrypted)));
+ }
+ }
// Create default profile if no profile file exists.
if (ProfileFiles.Count == 0)
@@ -288,9 +361,9 @@ public static void EnableEncryption(ProfileFileInfo profileFileInfo, SecureStrin
IsPasswordValid = true
};
- // Load the profiles from the profile file
- var profiles = DeserializeFromFile(profileFileInfo.Path);
-
+ List profiles = Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension
+ ? DeserializeFromXmlFile(profileFileInfo.Path)
+ : DeserializeFromFile(profileFileInfo.Path);
// Save the encrypted file
var decryptedBytes = SerializeToByteArray(profiles);
var encryptedBytes = CryptoHelper.Encrypt(decryptedBytes,
@@ -348,7 +421,12 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS
var decryptedBytes = CryptoHelper.Decrypt(encryptedBytes, SecureStringHelper.ConvertToString(password),
GlobalStaticConfiguration.Profile_EncryptionKeySize,
GlobalStaticConfiguration.Profile_EncryptionIterations);
- var profiles = DeserializeFromByteArray(decryptedBytes);
+
+ List profiles;
+
+ profiles = IsXmlContent(decryptedBytes)
+ ? DeserializeFromXmlByteArray(decryptedBytes)
+ : DeserializeFromByteArray(decryptedBytes);
// Save the encrypted file
decryptedBytes = SerializeToByteArray(profiles);
@@ -362,7 +440,6 @@ public static void ChangeMasterPassword(ProfileFileInfo profileFileInfo, SecureS
// Add the new profile
ProfileFiles.Add(newProfileFileInfo);
-
// Switch profile, if it was previously loaded
if (switchProfile)
{
@@ -399,8 +476,10 @@ public static void DisableEncryption(ProfileFileInfo profileFileInfo, SecureStri
var decryptedBytes = CryptoHelper.Decrypt(encryptedBytes, SecureStringHelper.ConvertToString(password),
GlobalStaticConfiguration.Profile_EncryptionKeySize,
GlobalStaticConfiguration.Profile_EncryptionIterations);
- var profiles = DeserializeFromByteArray(decryptedBytes);
+ List profiles = IsXmlContent(decryptedBytes)
+ ? DeserializeFromXmlByteArray(decryptedBytes)
+ : DeserializeFromByteArray(decryptedBytes);
// Save the decrypted profiles to the profile file
SerializeToFile(newProfileFileInfo.Path, profiles);
@@ -431,8 +510,11 @@ private static void Load(ProfileFileInfo profileFileInfo)
{
var loadedProfileUpdated = false;
+ Log.Info($"Load profile file: {profileFileInfo.Path}");
+
if (File.Exists(profileFileInfo.Path))
{
+ // Encrypted profile file
if (profileFileInfo.IsEncrypted)
{
var encryptedBytes = File.ReadAllBytes(profileFileInfo.Path);
@@ -441,16 +523,105 @@ private static void Load(ProfileFileInfo profileFileInfo)
GlobalStaticConfiguration.Profile_EncryptionKeySize,
GlobalStaticConfiguration.Profile_EncryptionIterations);
- AddGroups(DeserializeFromByteArray(decryptedBytes));
+ List groups;
+
+ if (IsXmlContent(decryptedBytes))
+ {
+ //
+ // MIGRATION FROM LEGACY XML PROFILE FILE
+ //
+
+ Log.Info($"Legacy XML profile file detected inside encrypted profile: {profileFileInfo.Path}. Migration in progress...");
+
+ // Load from legacy XML byte array
+ groups = DeserializeFromXmlByteArray(decryptedBytes);
+
+ // Create a backup of the legacy XML file
+ Backup(profileFileInfo.Path,
+ GetSettingsBackupFolderLocation(),
+ TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path)));
+
+ // Save encrypted profile file with new JSON format
+ var newDecryptedBytes = SerializeToByteArray([.. groups]);
+ var newEncryptedBytes = CryptoHelper.Encrypt(newDecryptedBytes,
+ SecureStringHelper.ConvertToString(profileFileInfo.Password),
+ GlobalStaticConfiguration.Profile_EncryptionKeySize,
+ GlobalStaticConfiguration.Profile_EncryptionIterations);
+
+ File.WriteAllBytes(profileFileInfo.Path, newEncryptedBytes);
+
+ Log.Info($"Legacy XML profile file migration completed inside encrypted profile: {profileFileInfo.Path}.");
+ }
+ else
+ {
+ groups = DeserializeFromByteArray(decryptedBytes);
+ }
+
+ AddGroups(groups);
// Password is valid
ProfileFiles.FirstOrDefault(x => x.Equals(profileFileInfo))!.IsPasswordValid = true;
profileFileInfo.IsPasswordValid = true;
loadedProfileUpdated = true;
}
+ // Unencrypted profile file
else
{
- AddGroups(DeserializeFromFile(profileFileInfo.Path));
+ List groups;
+
+ if (Path.GetExtension(profileFileInfo.Path) == LegacyProfileFileExtension)
+ {
+ //
+ // MIGRATION FROM LEGACY XML PROFILE FILE
+ //
+ Log.Info($"Legacy XML profile file detected: {profileFileInfo.Path}. Migration in progress...");
+
+ // Load from legacy XML file
+ groups = DeserializeFromXmlFile(profileFileInfo.Path);
+
+ ProfilesChanged = false;
+
+ LoadedProfileFile = profileFileInfo;
+
+ // Create a backup of the legacy XML file and delete the original
+ Backup(profileFileInfo.Path,
+ GetSettingsBackupFolderLocation(),
+ TimestampHelper.GetTimestampFilename(Path.GetFileName(profileFileInfo.Path)));
+
+ // Create new profile file info with JSON extension
+ var newProfileFileInfo = new ProfileFileInfo(profileFileInfo.Name,
+ Path.ChangeExtension(profileFileInfo.Path, ProfileFileExtension));
+
+ // Save new JSON file
+ SerializeToFile(newProfileFileInfo.Path, groups);
+
+ // Notify migration started
+ ProfileMigrationStarted();
+
+ // Add the new profile
+ ProfileFiles.Add(newProfileFileInfo);
+
+ // Switch profile
+ Log.Info($"Switching to migrated profile file: {newProfileFileInfo.Path}.");
+ Switch(newProfileFileInfo, false);
+ LoadedProfileFileChanged(LoadedProfileFile, true);
+
+ // Remove the old profile file
+ File.Delete(profileFileInfo.Path);
+ ProfileFiles.Remove(profileFileInfo);
+
+ // Notify migration completed
+ ProfileMigrationCompleted();
+
+ Log.Info($"Legacy XML profile file migration completed: {profileFileInfo.Path}.");
+ return;
+ }
+ else
+ {
+ groups = DeserializeFromFile(profileFileInfo.Path);
+ }
+
+ AddGroups(groups);
}
}
else
@@ -480,7 +651,7 @@ public static void Save()
return;
}
-
+ // Ensure the profiles directory exists.
Directory.CreateDirectory(GetProfilesFolderLocation());
// Write to an xml file.
@@ -539,17 +710,15 @@ public static void Switch(ProfileFileInfo info, bool saveLoadedProfiles = true)
#region Serialize and deserialize
///
- /// Method to serialize a list of groups as to an xml file.
+ /// Method to serialize a list of groups as to a JSON file.
///
- /// Path to an xml file.
+ /// Path to a JSON file.
/// List of the groups as to serialize.
private static void SerializeToFile(string filePath, List groups)
{
- var xmlSerializer = new XmlSerializer(typeof(List));
+ var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions);
- using var fileStream = new FileStream(filePath, FileMode.Create);
-
- xmlSerializer.Serialize(fileStream, SerializeGroup(groups));
+ File.WriteAllText(filePath, jsonString);
}
///
@@ -559,15 +728,9 @@ private static void SerializeToFile(string filePath, List groups)
/// Serialized list of groups as as byte array.
private static byte[] SerializeToByteArray(List groups)
{
- var xmlSerializer = new XmlSerializer(typeof(List));
-
- using var memoryStream = new MemoryStream();
-
- using var streamWriter = new StreamWriter(memoryStream, Encoding.UTF8);
-
- xmlSerializer.Serialize(streamWriter, SerializeGroup(groups));
+ var jsonString = JsonSerializer.Serialize(SerializeGroup(groups), JsonOptions);
- return memoryStream.ToArray();
+ return Encoding.UTF8.GetBytes(jsonString);
}
///
@@ -577,7 +740,7 @@ private static byte[] SerializeToByteArray(List groups)
/// Serialized list of groups as .
private static List SerializeGroup(List groups)
{
- List groupsSerializable = new();
+ List groupsSerializable = [];
foreach (var group in groups)
{
@@ -629,40 +792,125 @@ private static List SerializeGroup(List groups
}
///
- /// Method to deserialize a list of groups as from an xml file.
+ /// Method to deserialize a list of groups as from a JSON file.
///
- /// Path to an xml file.
+ /// Path to a JSON file.
/// List of groups as .
private static List DeserializeFromFile(string filePath)
+ {
+ var jsonString = File.ReadAllText(filePath);
+
+ return DeserializeFromJson(jsonString);
+ }
+
+ ///
+ /// Method to deserialize a list of groups as from a legacy XML file.
+ ///
+ /// Path to an XML file.
+ /// List of groups as .
+ [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")]
+ private static List DeserializeFromXmlFile(string filePath)
{
using FileStream fileStream = new(filePath, FileMode.Open);
- return DeserializeGroup(fileStream);
+ return DeserializeFromXmlStream(fileStream);
}
///
/// Method to deserialize a list of groups as from a byte array.
///
- /// Serialized list of groups as as byte array.
+ /// Serialized list of groups as as byte array.
/// List of groups as .
- private static List DeserializeFromByteArray(byte[] xml)
+ private static List DeserializeFromByteArray(byte[] data)
+ {
+ var jsonString = Encoding.UTF8.GetString(data);
+
+ return DeserializeFromJson(jsonString);
+ }
+
+ ///
+ /// Method to deserialize a list of groups as from a legacy XML byte array.
+ ///
+ /// Serialized list of groups as as XML byte array.
+ /// List of groups as .
+ [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")]
+ private static List DeserializeFromXmlByteArray(byte[] xml)
{
using MemoryStream memoryStream = new(xml);
- return DeserializeGroup(memoryStream);
+ return DeserializeFromXmlStream(memoryStream);
}
///
- /// Method to deserialize a list of groups as .
+ /// Method to deserialize a list of groups as from JSON string.
+ ///
+ /// JSON string to deserialize.
+ /// List of groups as .
+ private static List DeserializeFromJson(string jsonString)
+ {
+ var groupsSerializable = JsonSerializer.Deserialize>(jsonString, JsonOptions);
+
+ if (groupsSerializable == null)
+ throw new InvalidOperationException("Failed to deserialize JSON profile file.");
+
+ return DeserializeGroup(groupsSerializable);
+ }
+
+ ///
+ /// Method to deserialize a list of groups as from an XML stream.
///
/// Stream to deserialize.
/// List of groups as .
- private static List DeserializeGroup(Stream stream)
+ [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")]
+ private static List DeserializeFromXmlStream(Stream stream)
{
XmlSerializer xmlSerializer = new(typeof(List));
- return (from groupSerializable in ((List)xmlSerializer.Deserialize(stream))!
- let profiles = groupSerializable.Profiles.Select(profileSerializable => new ProfileInfo(profileSerializable)
+ var groupsSerializable = xmlSerializer.Deserialize(stream) as List;
+
+ if (groupsSerializable == null)
+ throw new InvalidOperationException("Failed to deserialize XML profile file.");
+
+ return DeserializeGroup(groupsSerializable);
+ }
+
+ ///
+ /// Method to check if the byte array content is XML.
+ ///
+ /// Byte array to check.
+ /// True if the content is XML.
+ [Obsolete("Legacy XML profile files are no longer used, but the method is kept for migration purposes.")]
+ private static bool IsXmlContent(byte[] data)
+ {
+ if (data == null || data.Length == 0)
+ return false;
+
+ try
+ {
+ // Only check the first few bytes for performance
+ var bytesToCheck = Math.Min(XmlDetectionBufferSize, data.Length);
+ var text = Encoding.UTF8.GetString(data, 0, bytesToCheck).TrimStart();
+ // Check for XML declaration or root element that matches profile structure
+ return text.StartsWith("
+ /// Method to deserialize a list of groups as .
+ ///
+ /// List of serializable groups to deserialize.
+ /// List of groups as .
+ private static List DeserializeGroup(List groupsSerializable)
+ {
+ if (groupsSerializable == null)
+ throw new ArgumentNullException(nameof(groupsSerializable));
+
+ return [.. from groupSerializable in groupsSerializable
+ let profiles = (groupSerializable.Profiles ?? new List()).Select(profileSerializable => new ProfileInfo(profileSerializable)
{
// Migrate old tags to new tags list
// if TagsList is null or empty and Tags is not null or empty, split Tags by ';' and create a new ObservableSetCollection
@@ -713,7 +961,7 @@ private static List DeserializeGroup(Stream stream)
SNMP_Priv = !string.IsNullOrEmpty(groupSerializable.SNMP_Priv)
? SecureStringHelper.ConvertToSecureString(groupSerializable.SNMP_Priv)
: null
- }).ToList();
+ }];
}
#endregion
@@ -867,4 +1115,28 @@ public static void RemoveProfiles(IEnumerable profiles)
}
#endregion
-}
\ No newline at end of file
+
+ #region Backup
+
+ ///
+ /// Creates a backup of the specified profile file in the given backup folder with the provided backup file name.
+ ///
+ /// The full path to the profile file to back up. Cannot be null or empty.
+ /// The directory path where the backup file will be stored. If the directory does not exist, it will be created.
+ /// The name to use for the backup file within the backup folder. Cannot be null or empty.
+ private static void Backup(string filePath, string backupFolderPath, string backupFileName)
+ {
+ // Create the backup directory if it does not exist
+ Directory.CreateDirectory(backupFolderPath);
+
+ // Create the backup file path
+ var backupFilePath = Path.Combine(backupFolderPath, backupFileName);
+
+ // Copy the current profile file to the backup location
+ File.Copy(filePath, backupFilePath, true);
+
+ Log.Info($"Backup created: {backupFilePath}");
+ }
+
+ #endregion
+}
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index 61f95802ac..7c0041f063 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -327,10 +327,10 @@ private static void CleanupBackups(string backupFolderPath, string settingsFileN
///
/// Creates a backup of the specified settings file in the given backup folder with the provided backup file name.
///
- /// The full path to the settings file to back up. Cannot be null or empty.
+ /// The full path to the settings file to back up. Cannot be null or empty.
/// The directory path where the backup file will be stored. If the directory does not exist, it will be created.
/// The name to use for the backup file within the backup folder. Cannot be null or empty.
- private static void Backup(string settingsFilePath, string backupFolderPath, string backupFileName)
+ private static void Backup(string filePath, string backupFolderPath, string backupFileName)
{
// Create the backup directory if it does not exist
Directory.CreateDirectory(backupFolderPath);
@@ -339,7 +339,7 @@ private static void Backup(string settingsFilePath, string backupFolderPath, str
var backupFilePath = Path.Combine(backupFolderPath, backupFileName);
// Copy the current settings file to the backup location
- File.Copy(settingsFilePath, backupFilePath, true);
+ File.Copy(filePath, backupFilePath, true);
Log.Info($"Backup created: {backupFilePath}");
}
diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs
index 75f86b6cc8..6546c727be 100644
--- a/Source/NETworkManager/MainWindow.xaml.cs
+++ b/Source/NETworkManager/MainWindow.xaml.cs
@@ -33,7 +33,6 @@
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Threading;
-using static System.Runtime.InteropServices.JavaScript.JSType;
using Application = System.Windows.Application;
using ContextMenu = System.Windows.Controls.ContextMenu;
using MouseEventArgs = System.Windows.Forms.MouseEventArgs;
@@ -1376,6 +1375,8 @@ private void LoadProfiles()
_isProfileFilesLoading = false;
ProfileManager.OnLoadedProfileFileChangedEvent += ProfileManager_OnLoadedProfileFileChangedEvent;
+ ProfileManager.OnProfileMigrationStarted += ProfileManager_OnProfileMigrationStarted;
+ ProfileManager.OnProfileMigrationCompleted += ProfileManager_OnProfileMigrationCompleted;
SelectedProfileFile = ProfileFiles.SourceCollection.Cast()
.FirstOrDefault(x => x.Name == SettingsManager.Current.Profiles_LastSelected);
@@ -1481,6 +1482,16 @@ private void ProfileManager_OnLoadedProfileFileChangedEvent(object sender, Profi
_isProfileFileUpdating = false;
}
+ private void ProfileManager_OnProfileMigrationCompleted(object sender, EventArgs e)
+ {
+ _isProfileFileUpdating = false;
+ }
+
+ private void ProfileManager_OnProfileMigrationStarted(object sender, EventArgs e)
+ {
+ _isProfileFileUpdating = true;
+ }
+
#endregion
#region Update check
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index db4f8873e5..28bae845be 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -29,6 +29,16 @@ Release date: **xx.xx.2025**
:::
+- Profile and settings files have been migrated from `XML` to `JSON`. Existing files will be automatically converted to `JSON` on first load after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282) [#3299](https://github.com/BornToBeRoot/NETworkManager/pull/3299)
+
+ :::info
+
+ Starting with this release, new profile and settings files are created in `JSON` format. Existing `XML` files will be converted automatically on first load after upgrading. Automatic support for the migration will be provided until at least `2027`; after that only `JSON` files will be supported and very old installations may require an interim update.
+
+ The migration process creates a backup of the original files in the `Backups` subfolder of the settings and profiles directories. You can restore the originals from that folder if needed, but it's recommended to make a separate backup of your profile and settings files before updating.
+
+ :::
+
## What's new?
- New language Ukrainian (`uk-UA`) has been added. Thanks to [@vadickkt](https://github.com/vadickkt) [#3240](https://github.com/BornToBeRoot/NETworkManager/pull/3240)
@@ -47,13 +57,14 @@ Release date: **xx.xx.2025**
**Profiles**
+- Profile file format migrated from `XML` to `JSON`. The profile file will be automatically converted on first load after the update. [#3299](https://github.com/BornToBeRoot/NETworkManager/pull/3299)
- Profile file creation flow improved — when adding a new profile you are now prompted to enable profile-file encryption to protect stored credentials and settings. [#3227](https://github.com/BornToBeRoot/NETworkManager/pull/3227)
- Profile file dialog migrated to a child window to improve usability. [#3227](https://github.com/BornToBeRoot/NETworkManager/pull/3227)
- Credential dialogs migrated to child windows to improve usability. [#3231](https://github.com/BornToBeRoot/NETworkManager/pull/3231)
**Settings**
-- Settings format migrated from `XML` to `JSON`. The settings file will be automatically converted on first start after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282)
+- Settings file format migrated from `XML` to `JSON`. The settings file will be automatically converted on first load after the update. [#3282](https://github.com/BornToBeRoot/NETworkManager/pull/3282)
- Create a daily backup of the settings file before saving changes. Up to `10` backup files are kept in the `Backups` subfolder of the settings directory. [#3283](https://github.com/BornToBeRoot/NETworkManager/pull/3283)
**Dashboard**