diff --git a/README.md b/README.md
index dcb9a2a..911a731 100644
--- a/README.md
+++ b/README.md
@@ -52,9 +52,24 @@ Before installing the CPDLC Plugin, ensure you have the following:
### Configuring Labels and Strips
-The plugin provides custom label and strip items that must be added to your profile's `Labels.xml` and `Strips.xml` files. See [Labels.xml.diff](Labels.xml.diff) and [Strips.xml.diff](Strips.xml.diff) for complete examples.
+The plugin provides custom label and strip items that must be added to your profile's `Labels.xml` and `Strips.xml` files.
-#### Labels.xml
+#### Automatic (recommended)
+
+After launching vatSys with the plugin installed, use the `CPDLC > Install label & strip items` menu option to add the custom label and strip items. Restart vatSys to apply the changes.
+
+To revert, use `CPDLC > Uninstall label & strip items`, which restores the original files from a backup.
+
+If your `Labels.xml` or `Strips.xml` files have been customised, the automatic installer will fail. Apply the changes manually using the diffs below.
+
+> [!NOTE]
+> When bundling the plugin in a vatSys profile, you can hide the install and uninstall menu items by setting `ShowInstallationMenuItems` to `false` in `CPDLC.json`.
+
+#### Manual
+
+See [Labels.xml.diff](Labels.xml.diff) and [Strips.xml.diff](Strips.xml.diff) for complete examples.
+
+##### Labels.xml
Replace each occurrence of:
```xml
@@ -72,7 +87,7 @@ With:
```
-#### Strips.xml
+##### Strips.xml
Replace:
```xml
diff --git a/docs/docs/vatsys-plugin/configuration.md b/docs/docs/vatsys-plugin/configuration.md
index f739683..75947b3 100644
--- a/docs/docs/vatsys-plugin/configuration.md
+++ b/docs/docs/vatsys-plugin/configuration.md
@@ -117,6 +117,17 @@ Default: `1`
"NdaRecalculationIntervalMinutes": 1
```
+### ShowInstallationMenuItems
+
+Controls whether the `Install label & strip items` and `Uninstall label & strip items` entries appear under the `CPDLC` menu. Set to `false` when shipping the plugin with pre-modified profiles to prevent controllers from accidentally reverting the changes.
+
+Default: `true`
+
+**Example:**
+```json
+"ShowInstallationMenuItems": false
+```
+
## Uplink Messages
The `UplinkMessages` object defines the available CPDLC messages for the controller to send.
diff --git a/docs/docs/vatsys-plugin/installation.md b/docs/docs/vatsys-plugin/installation.md
index 7d32d9e..613fb2b 100644
--- a/docs/docs/vatsys-plugin/installation.md
+++ b/docs/docs/vatsys-plugin/installation.md
@@ -49,6 +49,20 @@ vatSys requires separate items for foreground content and background color. The
vatSys uses the Eurocat CPDLC symbols (`.`, `-`, `+`) for voice capabilities on VATSIM. The plugin repurposes these symbols for their intended CPDLC function, and provides `CPDLCPLUGIN_TEXTSTATUS` as a replacement voice capability indicator.
+#### Automatic
+
+After launching vatSys with the plugin installed, use the `CPDLC > Install label & strip items` menu option to add the custom label and strip items. Restart vatSys to apply the changes.
+
+To revert, use `CPDLC > Uninstall label & strip items`, which restores the original files from a backup.
+
+If your `Labels.xml` or `Strips.xml` files have been customised, the automatic installer will fail. Apply the changes manually using the sections below.
+
+:::note
+When bundling the plugin in a vatSys profile, you can hide the install and uninstall menu items by setting `ShowInstallationMenuItems` to `false` in `CPDLC.json`.
+:::
+
+#### Manual
+
**Labels.xml:** Replace `LABEL_ITEM_CPDLC` with:
```xml
diff --git a/source/CPDLCPlugin/Configuration/ConfigurationLoader.cs b/source/CPDLCPlugin/Configuration/ConfigurationLoader.cs
index fb58b9e..9ab925c 100644
--- a/source/CPDLCPlugin/Configuration/ConfigurationLoader.cs
+++ b/source/CPDLCPlugin/Configuration/ConfigurationLoader.cs
@@ -2,7 +2,6 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
-using vatsys;
namespace CPDLCPlugin.Configuration;
@@ -15,7 +14,7 @@ public static PluginConfiguration Load()
var searchDirectories = new List();
// Search the profile first
- if (TryFindProfileDirectory(out var profileDirectory))
+ if (ProfileDirectoryResolver.TryGetProfileDirectory(out var profileDirectory))
{
searchDirectories.AddRange([
Path.Combine(profileDirectory.FullName, "Plugins", "Configs", "CPDLC Plugin"),
@@ -56,18 +55,4 @@ public static PluginConfiguration Load()
return configuration;
}
-
- // Thanks Max!
- static bool TryFindProfileDirectory(out DirectoryInfo? directoryInfo)
- {
- directoryInfo = null;
- if (!Profile.Loaded)
- return false;
-
- var shortNameObject = typeof(Profile).GetField("shortName", BindingFlags.Static | BindingFlags.NonPublic);
- var shortName = (string)shortNameObject.GetValue(shortNameObject);
-
- directoryInfo = new DirectoryInfo(Path.Combine(Helpers.GetFilesFolder(), "Profiles", shortName));
- return true;
- }
}
diff --git a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs
index 9bca693..878d695 100644
--- a/source/CPDLCPlugin/Configuration/PluginConfiguration.cs
+++ b/source/CPDLCPlugin/Configuration/PluginConfiguration.cs
@@ -21,4 +21,9 @@ public class PluginConfiguration
// How often (in minutes) to recalculate the NDA for all tracked aircraft.
// NDA is also recalculated on every FDR update, so this is a catch-all interval.
public int NdaRecalculationIntervalMinutes { get; init; } = 1;
+
+ // When false, the Install/Uninstall label & strip items menu entries are hidden.
+ // ACCs shipping the plugin with pre-modified profiles can disable these to prevent
+ // controllers from accidentally reverting the changes.
+ public bool ShowInstallationMenuItems { get; init; } = true;
}
diff --git a/source/CPDLCPlugin/Configuration/ProfileDirectoryResolver.cs b/source/CPDLCPlugin/Configuration/ProfileDirectoryResolver.cs
new file mode 100644
index 0000000..b5fcd1c
--- /dev/null
+++ b/source/CPDLCPlugin/Configuration/ProfileDirectoryResolver.cs
@@ -0,0 +1,21 @@
+using System.IO;
+using System.Reflection;
+using vatsys;
+
+namespace CPDLCPlugin.Configuration;
+
+public static class ProfileDirectoryResolver
+{
+ public static bool TryGetProfileDirectory(out DirectoryInfo? directoryInfo)
+ {
+ directoryInfo = null;
+ if (!Profile.Loaded)
+ return false;
+
+ var shortNameObject = typeof(Profile).GetField("shortName", BindingFlags.Static | BindingFlags.NonPublic);
+ var shortName = (string)shortNameObject.GetValue(shortNameObject);
+
+ directoryInfo = new DirectoryInfo(Path.Combine(Helpers.GetFilesFolder(), "Profiles", shortName));
+ return true;
+ }
+}
diff --git a/source/CPDLCPlugin/Configuration/XmlInstaller.cs b/source/CPDLCPlugin/Configuration/XmlInstaller.cs
new file mode 100644
index 0000000..a39f894
--- /dev/null
+++ b/source/CPDLCPlugin/Configuration/XmlInstaller.cs
@@ -0,0 +1,168 @@
+using System.IO;
+
+namespace CPDLCPlugin.Configuration;
+
+public static class XmlInstaller
+{
+ const string BackupSuffix = ".cpdlcplugin.bak";
+ const string InstalledMarker = "CPDLCPLUGIN_CPDLCSTATUS";
+
+ // Replace the CPDLC/WAKETURBCAT/FIELD18RMK block because the new layout inserts CPDLCSTATUS
+ // items before WAKETURBCAT and appends TEXTSTATUS items after FIELD18RMK.
+ const string LabelsOldBlock =
+ " \r\n \r\n ";
+
+ const string LabelsNewBlock =
+ " \r\n \r\n \r\n\r\n \r\n \r\n\r\n \r\n \r\n ";
+
+ const string StripsOldItem =
+ " ";
+
+ const string StripsNewItem =
+ " \r\n \r\n\r\n \r\n ";
+
+ public static InstallationStatus GetStatus()
+ {
+ if (!TryFindConfigFiles(out var labels, out var strips, out var error))
+ return InstallationStatus.Error(error!);
+
+ var labelsInstalled = File.Exists(labels) && File.ReadAllText(labels).Contains(InstalledMarker);
+ var stripsInstalled = File.Exists(strips) && File.ReadAllText(strips).Contains(InstalledMarker);
+
+ if (labelsInstalled && stripsInstalled)
+ return InstallationStatus.Installed();
+
+ return InstallationStatus.NotInstalled();
+ }
+
+ public static InstallResult Install()
+ {
+ if (!TryFindConfigFiles(out var labels, out var strips, out var error))
+ return InstallResult.Failed(error!);
+
+ try
+ {
+ PatchFile(labels, LabelsOldBlock, LabelsNewBlock);
+ PatchFile(strips, StripsOldItem, StripsNewItem);
+ return InstallResult.Success();
+ }
+ catch (InstallException ex)
+ {
+ return InstallResult.Failed(ex.Message);
+ }
+ }
+
+ public static InstallResult Uninstall()
+ {
+ if (!TryFindConfigFiles(out var labels, out var strips, out var error))
+ return InstallResult.Failed(error!);
+
+ try
+ {
+ RestoreFile(labels);
+ RestoreFile(strips);
+ return InstallResult.Success();
+ }
+ catch (InstallException ex)
+ {
+ return InstallResult.Failed(ex.Message);
+ }
+ }
+
+ static void PatchFile(string filePath, string oldText, string newText)
+ {
+ if (!File.Exists(filePath))
+ throw new InstallException($"File not found: {filePath}");
+
+ var backupPath = filePath + BackupSuffix;
+
+ // If a backup exists, restore from it as the base to avoid stacking patches.
+ // Otherwise, create a backup from the current file.
+ string content;
+ if (File.Exists(backupPath))
+ {
+ content = File.ReadAllText(backupPath);
+ }
+ else
+ {
+ content = File.ReadAllText(filePath);
+ File.WriteAllText(backupPath, content);
+ }
+
+ if (!content.Contains(oldText))
+ {
+ throw new InstallException(
+ $"Could not find the expected content in {Path.GetFileName(filePath)}. " +
+ "The file may have been customised. Please apply the changes manually.");
+ }
+
+ // Replace all occurrences (Labels.xml has both a Normal and Extended label block).
+ var patched = content.Replace(oldText, newText);
+
+ File.WriteAllText(filePath, patched);
+ }
+
+ static void RestoreFile(string filePath)
+ {
+ var backupPath = filePath + BackupSuffix;
+ if (!File.Exists(backupPath))
+ {
+ throw new InstallException(
+ $"No backup found for {Path.GetFileName(filePath)}. " +
+ "The plugin's label & strip items must be removed manually.");
+ }
+
+ File.Copy(backupPath, filePath, overwrite: true);
+ File.Delete(backupPath);
+ }
+
+ static bool TryFindConfigFiles(out string labelsPath, out string stripsPath, out string? error)
+ {
+ labelsPath = string.Empty;
+ stripsPath = string.Empty;
+ error = null;
+
+ if (!ProfileDirectoryResolver.TryGetProfileDirectory(out var profileDir) || profileDir is null)
+ {
+ error = "Profile directory could not be resolved.";
+ return false;
+ }
+
+ labelsPath = Path.Combine(profileDir.FullName, "Labels.xml");
+ stripsPath = Path.Combine(profileDir.FullName, "Strips.xml");
+ return true;
+ }
+
+ class InstallException(string message) : Exception(message);
+}
+
+public readonly struct InstallationStatus
+{
+ public bool IsInstalled { get; }
+ public string? ErrorMessage { get; }
+
+ InstallationStatus(bool isInstalled, string? errorMessage)
+ {
+ IsInstalled = isInstalled;
+ ErrorMessage = errorMessage;
+ }
+
+ public static InstallationStatus Installed() => new(true, null);
+ public static InstallationStatus NotInstalled() => new(false, null);
+ public static InstallationStatus Error(string errorMessage) => new(false, errorMessage);
+}
+
+public readonly struct InstallResult
+{
+ public bool Succeeded { get; }
+ public string? ErrorMessage { get; }
+
+ InstallResult(bool succeeded, string? errorMessage)
+ {
+ Succeeded = succeeded;
+ ErrorMessage = errorMessage;
+ }
+
+ public static InstallResult Success() => new(true, null);
+ public static InstallResult Failed(string errorMessage) => new(false, errorMessage);
+}
diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs
index e2fed03..6bc66ac 100644
--- a/source/CPDLCPlugin/Plugin.cs
+++ b/source/CPDLCPlugin/Plugin.cs
@@ -44,6 +44,12 @@ public class Plugin : ILabelPlugin, IStripPlugin, IRecipient Name;
IServiceProvider ServiceProvider { get; set; }
@@ -65,6 +71,8 @@ public Plugin()
AddToolbarItems();
+ CheckXmlInstallation();
+
Network.Connected += NetworkConnected;
Network.Disconnected += NetworkDisconnected;
@@ -326,6 +334,119 @@ void AddToolbarItems()
};
MMI.AddCustomMenuItem(historyMenuItem);
+
+ if (_configuration?.ShowInstallationMenuItems ?? true)
+ {
+ var status = XmlInstaller.GetStatus();
+ if (!string.IsNullOrEmpty(status.ErrorMessage))
+ return;
+
+ _labelsInstalled = status.IsInstalled;
+
+ var toolStripItem = new ToolStripMenuItem(_labelsInstalled ? UninstallMenuLabel : InstallMenuLabel);
+ toolStripItem.Click += (_, _) =>
+ {
+ if (_labelsInstalled)
+ RunUninstall();
+ else
+ RunInstall();
+ };
+
+ _installationMenuItem = toolStripItem;
+
+ var menuItem = new CustomToolStripMenuItem(
+ CustomToolStripMenuItemWindowType.Main,
+ menuItemCategory,
+ toolStripItem);
+ MMI.AddCustomMenuItem(menuItem);
+ }
+ }
+
+ void CheckXmlInstallation()
+ {
+ try
+ {
+ var status = XmlInstaller.GetStatus();
+ if (!string.IsNullOrEmpty(status.ErrorMessage))
+ {
+ Log.Warning("Could not verify Labels.xml/Strips.xml installation: {Error}", status.ErrorMessage);
+ return;
+ }
+
+ if (!status.IsInstalled)
+ {
+ AddError(new InvalidOperationException("CPDLC Label and Strip items must be installed. Please install them from the CPDLC menu."));
+ }
+ }
+ catch (Exception ex)
+ {
+ AddError(ex, "Failed to verify Labels.xml/Strips.xml installation");
+ }
+ }
+
+ void RunInstall()
+ {
+ try
+ {
+ var result = XmlInstaller.Install();
+ if (result.Succeeded)
+ {
+ SetInstallationMenuState(installed: true);
+ System.Windows.Forms.MessageBox.Show(
+ "CPDLC label & strip items installed successfully.\n\nPlease restart vatSys to apply the changes.",
+ Name,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information);
+ }
+ else
+ {
+ System.Windows.Forms.MessageBox.Show(
+ $"Failed to install CPDLC label & strip items.\n\n{result.ErrorMessage}",
+ Name,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ AddError(ex, "Failed to install label & strip items");
+ }
+ }
+
+ void SetInstallationMenuState(bool installed)
+ {
+ _labelsInstalled = installed;
+ if (_installationMenuItem is not null)
+ _installationMenuItem.Text = installed ? UninstallMenuLabel : InstallMenuLabel;
+ }
+
+ void RunUninstall()
+ {
+ try
+ {
+ var result = XmlInstaller.Uninstall();
+ if (result.Succeeded)
+ {
+ SetInstallationMenuState(installed: false);
+ System.Windows.Forms.MessageBox.Show(
+ "CPDLC label & strip items uninstalled successfully.\n\nPlease restart vatSys to apply the changes.",
+ Name,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Information);
+ }
+ else
+ {
+ System.Windows.Forms.MessageBox.Show(
+ $"Failed to uninstall CPDLC label & strip items.\n\n{result.ErrorMessage}",
+ Name,
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Error);
+ }
+ }
+ catch (Exception ex)
+ {
+ AddError(ex, "Failed to uninstall label & strip items");
+ }
}
public void OnFDRUpdate(FDP2.FDR updated)