diff --git a/LANCommander.SDK/PowerShell/Cmdlets/Merge-IniValue.cs b/LANCommander.SDK/PowerShell/Cmdlets/Merge-IniValue.cs
new file mode 100644
index 000000000..f74a5389d
--- /dev/null
+++ b/LANCommander.SDK/PowerShell/Cmdlets/Merge-IniValue.cs
@@ -0,0 +1,511 @@
+using MadMilkman.Ini;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Management.Automation;
+using System.Text;
+
+namespace LANCommander.SDK.PowerShell.Cmdlets
+{
+ ///
+ /// Cmdlet for merging the contents of a source INI file into a destination INI file.
+ /// It supports merging the entire source file, a specified section, or a single key/value pair.
+ /// Optionally, the data can be extracted from the source (i.e. removed) after merging.
+ ///
+ [Cmdlet(VerbsData.Merge, "Ini")]
+ [OutputType(typeof(void))]
+ public class MergeIniCmdlet : Cmdlet
+ {
+ #region Runtime fields
+
+ protected IniOptions IniOptions = new IniOptions();
+
+ #endregion
+
+ #region Mandatory Parameters
+
+ ///
+ /// Specifies the file path to the source INI file.
+ ///
+ [Parameter(Mandatory = true, Position = 0, HelpMessage = "Specifies the file path to the source INI file.")]
+ [Alias("src")]
+ public string SourceFilePath { get; set; }
+
+ ///
+ /// Specifies the file path to the destination INI file.
+ ///
+ [Parameter(Mandatory = false, Position = 1, HelpMessage = "Specifies the file path to the destination INI file.")]
+ [Alias("dest")]
+ public string DestinationFilePath { get; set; }
+
+ #endregion
+
+ #region Optional Extraction and Filter Parameters
+
+ ///
+ /// Specifies the source INI section to extract or merge.
+ /// If omitted, the entire source file is merged.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the source INI section to extract or merge. If omitted, the entire source file is merged.")]
+ [Alias("ss", "ssec", "sourcesec")]
+ public string SourceSection { get; set; }
+
+ ///
+ /// Specifies the source key within the source section to extract or merge.
+ /// Used only when SourceSection is provided.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the source key to extract or merge from the source section.")]
+ [Alias("sk", "skey", "sourcek")]
+ public string SourceKey { get; set; }
+
+ ///
+ /// Specifies the destination section to which content should be merged.
+ /// If omitted, the source section name is used.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the destination INI section to merge into. If omitted, the source section name is used.")]
+ [Alias("ds", "dsec", "destsection")]
+ public string DestinationSection { get; set; }
+
+ ///
+ /// Specifies the destination key name when merging a single key.
+ /// If omitted, the source key name is used.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the destination key name when merging a single key. If omitted, the source key name is used.")]
+ [Alias("dk", "dkey", "destkey")]
+ public string DestinationKey { get; set; }
+
+ ///
+ /// If set, extracts (removes) the specified section or key from the source INI file
+ /// once they have been merged into the destination file.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "If set, extracts the specified section or key from the source INI file after merging.")]
+ public SwitchParameter Extract { get; set; } = false;
+
+ #endregion
+
+ #region Merge Behavior Parameters
+
+ ///
+ /// Controls whether the value will be wrapped in quotes.
+ /// Nullable: if null, preserves the quoting style of the existing destination value;
+ /// if true, always enforces quotes; if false, ensures any surrounding quotes are removed.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Controls whether the value will be wrapped in quotes. Nullable: if null, preserves the destination’s quoting style; if true, enforces quotes; if false, removes quotes.")]
+ [Alias("wrap", "quotes")]
+ public bool? WrapValueInQuotes { get; set; } = null;
+
+ ///
+ /// Determines whether to update an existing key or add a new one if it does not exist.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "If set, updates an existing key or adds a new one if not found in the destination.")]
+ [Alias("addkey")]
+ public SwitchParameter UpdateOrAddKey { get; set; } = true;
+
+ ///
+ /// Determines whether to update an existing section or add a new one if it does not exist.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "If set, updates an existing section or adds a new one if not found in the destination.")]
+ [Alias("addsection")]
+ public SwitchParameter UpdateOrAddSection { get; set; } = true;
+
+ ///
+ /// Clears all instances of a specific key in the destination before performing the merge.
+ /// When used with –PreserveKeys, the destination keys are cleared once per key name.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Clears all instances of the specified key in the destination before merging new values.")]
+ public SwitchParameter ClearKeys { get; set; } = false;
+
+ ///
+ /// When set, new key-value pairs are always appended in the destination,
+ /// even if the key already exists.
+ ///
+ [Alias("appendkey", "preserve")]
+ [Parameter(Mandatory = false, HelpMessage = "If set, new key-value pairs are always appended in the destination.")]
+ public SwitchParameter PreserveKeys { get; set; } = false;
+
+ ///
+ /// When set, new section is always appended in the destination,
+ /// even if the section already exists.
+ ///
+ [Alias("appendsection", "preserveSec")]
+ [Parameter(Mandatory = false, HelpMessage = "If set, new section is always appended in the destination.")]
+ public SwitchParameter PreserveSections { get; set; } = false;
+
+ ///
+ /// Specifies the zero-based index position at which to insert new key-value pair(s) in the destination.
+ /// If not provided, new entries are added at the end.
+ ///
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the index position at which to insert new key-value pair(s) in the destination.")]
+ [Alias("insert")]
+ public int? InsertIndex { get; set; } = null;
+
+ ///
+ /// Indicates whether duplicate keys are allowed within the destination section.
+ ///
+ [Alias("keepkey", "keydup")]
+ [Parameter(Mandatory = false, HelpMessage = "If true, duplicate keys are allowed within the destination section.")]
+ public bool KeepKeyDuplicates { get; set; } = true;
+
+ ///
+ /// Prevents duplicate keys from being present in the destination section.
+ ///
+ [Alias("nokey", "nokeydup")]
+ [Parameter(Mandatory = false, HelpMessage = "If set, duplicate keys in the destination section are prevented.")]
+ public SwitchParameter NoKeyDuplicates
+ {
+ get => new(!KeepKeyDuplicates);
+ set => KeepKeyDuplicates = !value.ToBool();
+ }
+
+ ///
+ /// Indicates whether duplicate sections are allowed in the destination INI file.
+ ///
+ [Alias("keepsec", "secdup")]
+ [Parameter(Mandatory = false, HelpMessage = "If true, duplicate sections in the destination INI file are allowed.")]
+ public bool KeepSectionDuplicates { get; set; } = true;
+
+ ///
+ /// Prevents duplicate sections from occurring in the destination INI file.
+ ///
+ [Alias("nosec", "nosecdup")]
+ [Parameter(Mandatory = false, HelpMessage = "If set, duplicate sections in the destination INI file are prevented.")]
+ public SwitchParameter NoSectionDuplicates
+ {
+ get => new(!KeepSectionDuplicates);
+ set => KeepSectionDuplicates = !value.ToBool();
+ }
+
+ ///
+ /// Specifies the encoding for reading and writing INI files.
+ /// Examples include 'UTF8', 'Latin', 'ASCII', or 'Unicode'.
+ ///
+ [Alias("encoding", "enc")]
+ [Parameter(Mandatory = false, HelpMessage = "Specifies the encoding for reading and writing INI files (e.g., UTF8, Latin, ASCII, Unicode).")]
+ public string Codepage { get; set; } = "Latin";
+
+ #endregion
+
+ #region Helper Methods
+
+ ///
+ /// Returns the appropriate based on the specified codepage.
+ ///
+ /// The for the chosen codepage.
+ protected Encoding GetEncoding()
+ {
+ string page = Codepage?.ToLower();
+ switch (page)
+ {
+ case "uft8":
+ return Encoding.UTF8;
+ case "latin":
+ case "latin1":
+ case "iso-8859-1":
+ return Encoding.Latin1;
+ case "asci":
+ case "ascii":
+ return Encoding.ASCII;
+ case "unicode":
+ return Encoding.Unicode;
+ }
+ return Encoding.Default;
+ }
+
+ ///
+ /// Applies quote wrapping for a new value based on the parameter.
+ /// When null, the quoting style of the existing destination value is preserved.
+ ///
+ /// The new value to process.
+ /// The existing value in the destination (if any) for reference.
+ /// The processed string with quotes applied or removed according to settings.
+ private string ApplyQuoteWrapping(string newValue, string existingValue)
+ {
+ if (string.IsNullOrEmpty(newValue))
+ return newValue;
+
+ bool isNewValueQuoted = (newValue.StartsWith("\"") && newValue.EndsWith("\"")) ||
+ (newValue.StartsWith("'") && newValue.EndsWith("'"));
+
+ bool isExistingValueQuoted = !string.IsNullOrEmpty(existingValue) &&
+ ((existingValue.StartsWith("\"") && existingValue.EndsWith("\"")) ||
+ (existingValue.StartsWith("'") && existingValue.EndsWith("'")));
+
+ if (WrapValueInQuotes == null)
+ {
+ // Preserve the quoting style of the existing destination value.
+ return isExistingValueQuoted ? (isNewValueQuoted ? newValue : $"\"{newValue}\"") : newValue;
+ }
+ else if (WrapValueInQuotes.Value)
+ {
+ // Enforce quotes.
+ return isNewValueQuoted ? newValue : $"\"{newValue}\"";
+ }
+ else
+ {
+ // Remove quotes.
+ return isNewValueQuoted ? newValue.Substring(1, newValue.Length - 2) : newValue;
+ }
+ }
+
+ #endregion
+
+ #region ProcessRecord
+
+ protected override void BeginProcessing()
+ {
+ base.BeginProcessing();
+
+ // Prepare INI file options.
+ IniOptions = new IniOptions()
+ {
+ Encoding = GetEncoding(),
+ SectionDuplicate = KeepSectionDuplicates ? IniDuplication.Allowed : IniDuplication.Ignored,
+ KeyDuplicate = KeepKeyDuplicates ? IniDuplication.Allowed : IniDuplication.Ignored,
+ };
+ }
+
+ ///
+ /// Processes the merge operation between the source and destination INI files based on the selected parameters.
+ /// If extraction is enabled and a specific section or key is specified, that content is removed from the source after merging.
+ ///
+ protected override void ProcessRecord()
+ {
+ // Validate source file.
+ if (!File.Exists(SourceFilePath))
+ {
+ WriteWarning("Source INI file not found.");
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(DestinationFilePath) && !Extract)
+ {
+ WriteWarning("Destination INI file not provided.");
+ return;
+ }
+
+ // Load the source INI file.
+ var srcIni = new IniFile(IniOptions);
+ srcIni.Load(SourceFilePath);
+
+ // Load or create the destination INI file.
+ var destIni = new IniFile(IniOptions);
+ bool isSameFile = false;
+ if (File.Exists(DestinationFilePath))
+ {
+ if (Path.GetFullPath(SourceFilePath) == Path.GetFullPath(DestinationFilePath))
+ {
+ destIni = srcIni;
+ isSameFile = true;
+ }
+ else
+ {
+ destIni.Load(DestinationFilePath);
+ }
+ }
+
+ // Case 1: Merge the entire source file (if no specific section is provided).
+ if (string.IsNullOrEmpty(SourceSection))
+ {
+ foreach (var srcSection in srcIni.Sections)
+ {
+ var dstSection = GetSection(destIni, srcSection.Name, isSameFile);
+ if (dstSection == null)
+ continue;
+
+ MergeSection(srcSection, dstSection);
+ }
+
+ if (Extract && !isSameFile)
+ {
+ WriteWarning("No source section was extracted. Extract is invalid with no SourceSection provided.");
+ }
+ }
+ else
+ {
+ // Case 2: A specific source section is provided.
+ var srcSec = srcIni.Sections[SourceSection];
+ if (srcSec == null)
+ {
+ WriteWarning($"Source section '{SourceSection}' not found.");
+ return;
+ }
+
+ // Use the provided destination section name if given; otherwise, default to the source section name.
+ string destSecName = string.IsNullOrEmpty(DestinationSection) ? srcSec.Name : DestinationSection;
+ var destSec = GetSection(destIni, destSecName, isSameFile);
+
+ // Case 2a: Merge the entire section if no specific source key is provided.
+ if (string.IsNullOrEmpty(SourceKey))
+ {
+ MergeSection(srcSec, destSec);
+ if (Extract && !isSameFile)
+ {
+ var sectionsToRemove = srcIni.Sections.Where(sec => IsMatchingKey(sec.Name, srcSec.Name)).ToList();
+ sectionsToRemove.ForEach(name => srcIni.Sections.Remove(name));
+ }
+ }
+ else
+ {
+ // Case 2b: Merge multiple values for a specific key.
+ var srcKeys = srcSec.Keys.Where(k => IsMatchingKey(k.Name, SourceKey)).ToList();
+ if (srcKeys.Count == 0)
+ {
+ WriteWarning($"Source key '{SourceKey}' not found in section '{SourceSection}'.");
+ return;
+ }
+
+ string destKeyName = string.IsNullOrEmpty(DestinationKey) ? SourceKey : DestinationKey;
+ bool isSameKey = string.Equals(SourceKey, destKeyName, StringComparison.OrdinalIgnoreCase);
+
+ if (ClearKeys && (!isSameFile || !isSameKey))
+ {
+ var keysToRemove = destSec.Keys.Where(k => IsMatchingKey(k.Name, destKeyName)).ToList();
+ keysToRemove.ForEach(k => destSec.Keys.Remove(k));
+ }
+
+ MergeKeys(srcKeys, destSec, destKeyName);
+
+ // If extraction is enabled for the key, remove all occurrences from the source section.
+ if (Extract && (!isSameFile || !isSameKey))
+ {
+ var keysToRemove = srcSec.Keys.Where(k => IsMatchingKey(k.Name, SourceKey)).ToList();
+ keysToRemove.ForEach(k => srcSec.Keys.Remove(k));
+ }
+ }
+ }
+
+ // Save the merged destination INI file.
+ if (!string.IsNullOrEmpty(DestinationFilePath))
+ destIni.Save(DestinationFilePath);
+
+ // If extraction occurred, save the updated source INI file.
+ if (Extract && destIni != srcIni)
+ {
+ srcIni.Save(SourceFilePath);
+ }
+ }
+
+ ///
+ /// Retrieves or creates an INI section based on the specified section name.
+ /// If an existing section matches the name, the last occurrence is returned.
+ /// If no matching section is found and conditions allow, a new section is added.
+ ///
+ /// The INI file object from which the section is retrieved or created.
+ /// The name of the section to search for or create.
+ /// If true, prevents adding a new section when no match is found.
+ ///
+ /// Returns the last matching section if found; otherwise, a new section is created unless is set to true.
+ ///
+ private IniSection GetSection(IniFile ini, string sectionName, bool NoAdd)
+ {
+ // assuming most of the engines interpret INI files from top to bottom using the last value of a multiple existing key, we update the last found key
+ var lastMatch = ini.Sections.LastOrDefault(sec => IsMatchingKey(sec.Name, sectionName));
+
+ if (PreserveSections.ToBool() || (UpdateOrAddSection && lastMatch == null))
+ {
+ if (!NoAdd)
+ {
+ lastMatch = new IniSection(ini, sectionName);
+ ini.Sections.Add(lastMatch);
+ }
+ }
+
+ return lastMatch;
+ }
+
+ ///
+ /// Merges an entire INI section into the destination file, handling multi-value keys.
+ ///
+ private void MergeSection(IniSection srcSection, IniSection dstSection)
+ {
+ // Use a hash set to track cleared keys so we only clear once per key name.
+ var clearedKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var srcKey in srcSection.Keys)
+ {
+ if (ClearKeys && !clearedKeys.Contains(srcKey.Name))
+ {
+ var keysToRemove = dstSection.Keys.Where(k => IsMatchingKey(k.Name, srcKey.Name)).ToList();
+ keysToRemove.ForEach(k => dstSection.Keys.Remove(k));
+ clearedKeys.Add(srcKey.Name);
+ }
+
+ // assuming most of the engines interpret INI files from top to bottom using the last value of a multiple existing key, we update the last found key
+ var lastMatch = dstSection.Keys.LastOrDefault(key => IsMatchingKey(key.Name, srcKey.Name));
+
+ // Adjust the value's surrounding quotes based on the WrapValueInQuotes parameter.
+ string newValue = ApplyQuoteWrapping(srcKey.Value, lastMatch?.Value);
+
+ // Append new key if PreserveKeys is set or no key exists (and UpdateOrAddKey allows adding).
+ if (PreserveKeys.ToBool() || (UpdateOrAddKey && lastMatch == null))
+ {
+ if (InsertIndex.HasValue && InsertIndex.Value >= 0)
+ dstSection.Keys.Insert(Math.Clamp(InsertIndex.Value, 0, dstSection.Keys.Count), srcKey.Name, newValue);
+ else
+ dstSection.Keys.Add(srcKey.Name, newValue);
+ }
+ else if (lastMatch != null)
+ {
+ // Update the existing key's value.
+ lastMatch.Value = newValue;
+ }
+ // No else clause – updating without adding a new key should be possible.
+ }
+ }
+
+ ///
+ /// Merges an entire INI section into the destination file, handling multi-value keys.
+ ///
+ private void MergeKeys(List srcKeys, IniSection destSec, string destKeyName)
+ {
+ // Use a hash set to track cleared keys so we only clear once per key name.
+ var clearedKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var srcKey in srcKeys)
+ {
+ if (ClearKeys && !clearedKeys.Contains(destKeyName))
+ {
+ var keysToRemove = destSec.Keys.Where(k => IsMatchingKey(k.Name, destKeyName)).ToList();
+ keysToRemove.ForEach(k => destSec.Keys.Remove(k));
+ clearedKeys.Add(destKeyName);
+ }
+
+ // assuming most of the engines interpret INI files from top to bottom using the last value of a multiple existing key, we update the last found key
+ var lastMatch = destSec.Keys.LastOrDefault(key => IsMatchingKey(key.Name, destKeyName));
+
+ // Adjust the value's surrounding quotes based on the WrapValueInQuotes parameter.
+ string newValue = ApplyQuoteWrapping(srcKey.Value, lastMatch?.Value);
+
+ // Append new key if PreserveKeys is set or no key exists (and UpdateOrAddKey allows adding).
+ if (PreserveKeys.ToBool() || (UpdateOrAddKey && lastMatch == null))
+ {
+ if (InsertIndex.HasValue && InsertIndex.Value >= 0)
+ destSec.Keys.Insert(Math.Clamp(InsertIndex.Value, 0, destSec.Keys.Count), destKeyName, newValue);
+ else
+ destSec.Keys.Add(destKeyName, newValue);
+ }
+ else if (lastMatch != null)
+ {
+ // Update the existing key's value.
+ lastMatch.Value = newValue;
+ }
+ // No else clause – updating without adding a new key should be possible.
+ }
+ }
+
+ ///
+ /// Determines whether two key names are considered a match using a case-insensitive comparison.
+ ///
+ /// The first key to compare.
+ /// The second key to compare.
+ ///
+ /// Returns true if the key names are equal (ignoring case); otherwise, false.
+ ///
+ public bool IsMatchingKey(string key, string otherKey)
+ {
+ return string.Equals(key, otherKey, IniOptions.KeyNameCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
+ }
+
+ #endregion
+ }
+}
diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs
index 545f345bb..eb726c8e1 100644
--- a/LANCommander.SDK/PowerShell/PowerShellScript.cs
+++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs
@@ -62,6 +62,7 @@ public PowerShellScript(ScriptType type)
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-GameManifest", typeof(GetGameManifestCmdlet), null));
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-PrimaryDisplay", typeof(GetPrimaryDisplayCmdlet), null));
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Update-IniValue", typeof(UpdateIniValueCmdlet), null));
+ InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Merge-Ini", typeof(MergeIniCmdlet), null));
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Write-GameManifest", typeof(WriteGameManifestCmdlet), null));
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Write-ReplaceContentInFile", typeof(ReplaceContentInFileCmdlet), null));
InitialSessionState.Commands.Add(new SessionStateCmdletEntry("Get-UserCustomField", typeof(GetUserCustomFieldCmdlet), null));
diff --git a/LANCommander.Server/LANCommander.Server.csproj b/LANCommander.Server/LANCommander.Server.csproj
index bd9a8dd51..a1a6df831 100644
--- a/LANCommander.Server/LANCommander.Server.csproj
+++ b/LANCommander.Server/LANCommander.Server.csproj
@@ -188,6 +188,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest
diff --git a/LANCommander.Server/Snippets/Functions/Merge-Ini.ps1 b/LANCommander.Server/Snippets/Functions/Merge-Ini.ps1
new file mode 100644
index 000000000..421e0610b
--- /dev/null
+++ b/LANCommander.Server/Snippets/Functions/Merge-Ini.ps1
@@ -0,0 +1 @@
+Merge-Ini -SourceFilePath "$InstallDirectory\patch.ini" -DestinationFilePath "$InstallDirectory\config.ini" -clear -append
\ No newline at end of file