From b644077e3633e8b5d029db1bec7ceb051ac189f7 Mon Sep 17 00:00:00 2001
From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com>
Date: Sat, 11 Jul 2026 16:21:03 +1000
Subject: [PATCH 1/4] wip: Auto-install CPDLC items
---
README.md | 21 +-
docs/docs/vatsys-plugin/configuration.md | 11 +
docs/docs/vatsys-plugin/installation.md | 14 ++
.../Configuration/ConfigurationLoader.cs | 17 +-
.../Configuration/PluginConfiguration.cs | 5 +
.../Configuration/ProfileDirectoryResolver.cs | 21 ++
.../CPDLCPlugin/Configuration/XmlInstaller.cs | 191 ++++++++++++++++++
source/CPDLCPlugin/Plugin.cs | 97 +++++++++
8 files changed, 358 insertions(+), 19 deletions(-)
create mode 100644 source/CPDLCPlugin/Configuration/ProfileDirectoryResolver.cs
create mode 100644 source/CPDLCPlugin/Configuration/XmlInstaller.cs
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..3d5ca05
--- /dev/null
+++ b/source/CPDLCPlugin/Configuration/XmlInstaller.cs
@@ -0,0 +1,191 @@
+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 =
+ """
+
+
+
+ """;
+
+ const string LabelsNewBlock =
+ """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ const string StripsOldItem =
+ """
+
+ """;
+
+ const string StripsNewItem =
+ """
+
+
+
+
+
+ """;
+
+ 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..538d2ff 100644
--- a/source/CPDLCPlugin/Plugin.cs
+++ b/source/CPDLCPlugin/Plugin.cs
@@ -65,6 +65,8 @@ public Plugin()
AddToolbarItems();
+ CheckXmlInstallation();
+
Network.Connected += NetworkConnected;
Network.Disconnected += NetworkDisconnected;
@@ -326,6 +328,101 @@ void AddToolbarItems()
};
MMI.AddCustomMenuItem(historyMenuItem);
+
+ if (_configuration?.ShowInstallationMenuItems ?? true)
+ {
+ var installMenuItem = new CustomToolStripMenuItem(
+ CustomToolStripMenuItemWindowType.Main,
+ menuItemCategory,
+ new ToolStripMenuItem("Install label & strip items"));
+ installMenuItem.Item.Click += (_, _) => RunInstall();
+ MMI.AddCustomMenuItem(installMenuItem);
+
+ var uninstallMenuItem = new CustomToolStripMenuItem(
+ CustomToolStripMenuItemWindowType.Main,
+ menuItemCategory,
+ new ToolStripMenuItem("Uninstall label & strip items"));
+ uninstallMenuItem.Item.Click += (_, _) => RunUninstall();
+ MMI.AddCustomMenuItem(uninstallMenuItem);
+ }
+ }
+
+ 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)
+ {
+ 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 RunUninstall()
+ {
+ try
+ {
+ var result = XmlInstaller.Uninstall();
+ if (result.Succeeded)
+ {
+ 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)
From 7b2184ff12ceea5b5b8981abe0c20b47103bd630 Mon Sep 17 00:00:00 2001
From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com>
Date: Sat, 11 Jul 2026 16:22:59 +1000
Subject: [PATCH 2/4] use a single button
---
source/CPDLCPlugin/Plugin.cs | 27 +++++++++++++++++----------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs
index 538d2ff..667ee71 100644
--- a/source/CPDLCPlugin/Plugin.cs
+++ b/source/CPDLCPlugin/Plugin.cs
@@ -331,19 +331,26 @@ void AddToolbarItems()
if (_configuration?.ShowInstallationMenuItems ?? true)
{
- var installMenuItem = new CustomToolStripMenuItem(
- CustomToolStripMenuItemWindowType.Main,
- menuItemCategory,
- new ToolStripMenuItem("Install label & strip items"));
- installMenuItem.Item.Click += (_, _) => RunInstall();
- MMI.AddCustomMenuItem(installMenuItem);
+ var status = XmlInstaller.GetStatus();
+ if (!string.IsNullOrEmpty(status.ErrorMessage))
+ return;
+
+ var label = status.IsInstalled
+ ? "Uninstall label items"
+ : "Install label items";
- var uninstallMenuItem = new CustomToolStripMenuItem(
+ var menuItem = new CustomToolStripMenuItem(
CustomToolStripMenuItemWindowType.Main,
menuItemCategory,
- new ToolStripMenuItem("Uninstall label & strip items"));
- uninstallMenuItem.Item.Click += (_, _) => RunUninstall();
- MMI.AddCustomMenuItem(uninstallMenuItem);
+ new ToolStripMenuItem(label));
+ menuItem.Item.Click += (_, _) =>
+ {
+ if (status.IsInstalled)
+ RunUninstall();
+ else
+ RunInstall();
+ };
+ MMI.AddCustomMenuItem(menuItem);
}
}
From a1a0862f54c4bd2ff855d33e797704d083387465 Mon Sep 17 00:00:00 2001
From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com>
Date: Sat, 11 Jul 2026 16:38:17 +1000
Subject: [PATCH 3/4] Fix string formatting
---
.../CPDLCPlugin/Configuration/XmlInstaller.cs | 31 +++----------------
1 file changed, 4 insertions(+), 27 deletions(-)
diff --git a/source/CPDLCPlugin/Configuration/XmlInstaller.cs b/source/CPDLCPlugin/Configuration/XmlInstaller.cs
index 3d5ca05..a39f894 100644
--- a/source/CPDLCPlugin/Configuration/XmlInstaller.cs
+++ b/source/CPDLCPlugin/Configuration/XmlInstaller.cs
@@ -10,39 +10,16 @@ public static class XmlInstaller
// 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()
{
From c783416ae1fbdfe737ea184431b43f7b01d59506 Mon Sep 17 00:00:00 2001
From: Eoin Motherway <25342760+YuKitsune@users.noreply.github.com>
Date: Sat, 11 Jul 2026 16:40:47 +1000
Subject: [PATCH 4/4] Swap menu item when install/uninstall completed
---
source/CPDLCPlugin/Plugin.cs | 35 ++++++++++++++++++++++++++---------
1 file changed, 26 insertions(+), 9 deletions(-)
diff --git a/source/CPDLCPlugin/Plugin.cs b/source/CPDLCPlugin/Plugin.cs
index 667ee71..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; }
@@ -335,21 +341,23 @@ void AddToolbarItems()
if (!string.IsNullOrEmpty(status.ErrorMessage))
return;
- var label = status.IsInstalled
- ? "Uninstall label items"
- : "Install label items";
+ _labelsInstalled = status.IsInstalled;
- var menuItem = new CustomToolStripMenuItem(
- CustomToolStripMenuItemWindowType.Main,
- menuItemCategory,
- new ToolStripMenuItem(label));
- menuItem.Item.Click += (_, _) =>
+ var toolStripItem = new ToolStripMenuItem(_labelsInstalled ? UninstallMenuLabel : InstallMenuLabel);
+ toolStripItem.Click += (_, _) =>
{
- if (status.IsInstalled)
+ if (_labelsInstalled)
RunUninstall();
else
RunInstall();
};
+
+ _installationMenuItem = toolStripItem;
+
+ var menuItem = new CustomToolStripMenuItem(
+ CustomToolStripMenuItemWindowType.Main,
+ menuItemCategory,
+ toolStripItem);
MMI.AddCustomMenuItem(menuItem);
}
}
@@ -383,6 +391,7 @@ void RunInstall()
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,
@@ -404,6 +413,13 @@ void RunInstall()
}
}
+ void SetInstallationMenuState(bool installed)
+ {
+ _labelsInstalled = installed;
+ if (_installationMenuItem is not null)
+ _installationMenuItem.Text = installed ? UninstallMenuLabel : InstallMenuLabel;
+ }
+
void RunUninstall()
{
try
@@ -411,6 +427,7 @@ void RunUninstall()
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,