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,