diff --git a/csharp/.gitignore b/csharp/.gitignore index 4b82ccd9149..1c87228407c 100644 --- a/csharp/.gitignore +++ b/csharp/.gitignore @@ -1,3 +1,4 @@ +*~ .vs/ bin/ obj/ diff --git a/csharp/ExcelAddIn/ExcelAddIn.csproj b/csharp/ExcelAddIn/ExcelAddIn.csproj index 4858c64169e..3e629260d04 100644 --- a/csharp/ExcelAddIn/ExcelAddIn.csproj +++ b/csharp/ExcelAddIn/ExcelAddIn.csproj @@ -21,4 +21,10 @@ + + + false + DeephavenExcelAddIn64 + + diff --git a/csharp/ExcelAddInInstaller/.gitignore b/csharp/ExcelAddInInstaller/.gitignore new file mode 100644 index 00000000000..2addde44586 --- /dev/null +++ b/csharp/ExcelAddInInstaller/.gitignore @@ -0,0 +1,2 @@ +ExcelAddInInstaller-SetupFiles/ +ExcelAddInInstaller-cache/ diff --git a/csharp/ExcelAddInInstaller/CustomActions/CustomActions.cs b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.cs new file mode 100644 index 00000000000..b5d996e49d3 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.cs @@ -0,0 +1,58 @@ +using System; + +namespace Deephaven.ExcelAddInInstaller.CustomActions { + public static class ErrorCodes { + // There are many, many possible error codes. + public const int Success = 0; + public const int Failure = 1603; + } + + public static class Functions { + public static int RegisterAddIn(string msiHandle) { + return RunHelper(msiHandle, "RegisterAddIn", sess => DoRegisterAddIn(sess, true)); + } + + public static int UnregisterAddIn(string msiHandle) { + return RunHelper(msiHandle, "UnregisterAddIn", sess => DoRegisterAddIn(sess, false)); + } + + private static int RunHelper(string msiHandle, string what, Action action) { + // First try to get a session + MsiSession session; + try { + session = new MsiSession(msiHandle); + } catch (Exception) { + // Didn't get very far + return ErrorCodes.Failure; + } + + // Now that we have a session, we can log failures to the session if we need to + try { + session.Log($"{what} starting", MsiSession.InstallMessage.INFO); + action(session); + session.Log($"{what} completed successfully", MsiSession.InstallMessage.INFO); + return ErrorCodes.Success; + } catch (Exception ex) { + session.Log(ex.Message, MsiSession.InstallMessage.ERROR); + session.Log($"{what} exited with error", MsiSession.InstallMessage.ERROR); + return ErrorCodes.Failure; + } + } + + private static void DoRegisterAddIn(MsiSession session, bool wantAddIn) { + var addInName = session.CustomActionData; + session.Log($"DoRegisterAddIn({wantAddIn}) with addin={addInName}", MsiSession.InstallMessage.INFO); + if (string.IsNullOrEmpty(addInName)) { + throw new ArgumentException("Expected addin path, got null or empty"); + } + + Action logger = s => session.Log(s, MsiSession.InstallMessage.INFO); + + if (!RegistryManager.TryMakeAddInEntryFromPath(addInName, out var addInEntry, out var failureReason) || + !RegistryManager.TryCreate(logger, out var rm, out failureReason) || + !rm.TryUpdateAddInKeys(addInEntry, wantAddIn, out failureReason)) { + throw new Exception(failureReason); + } + } + } +} diff --git a/csharp/ExcelAddInInstaller/CustomActions/CustomActions.csproj b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.csproj new file mode 100644 index 00000000000..a4d0e175883 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.csproj @@ -0,0 +1,58 @@ + + + + Debug + AnyCPU + 8.0.30703 + 2.0 + {2E432229-2429-499B-A2AB-69AB78A7EB21} + Library + Properties + CustomActions + CustomActions + v4.8 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/csharp/ExcelAddInInstaller/CustomActions/CustomActions.sln b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.sln new file mode 100644 index 00000000000..d4fa94ddb4d --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/CustomActions.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35222.181 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CustomActions", "CustomActions.csproj", "{2E432229-2429-499B-A2AB-69AB78A7EB21}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestCustomActions", "..\TestCustomActions\TestCustomActions.csproj", "{8DD17371-1835-49D6-A8D6-741B9AE504DC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2E432229-2429-499B-A2AB-69AB78A7EB21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E432229-2429-499B-A2AB-69AB78A7EB21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E432229-2429-499B-A2AB-69AB78A7EB21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E432229-2429-499B-A2AB-69AB78A7EB21}.Release|Any CPU.Build.0 = Release|Any CPU + {8DD17371-1835-49D6-A8D6-741B9AE504DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DD17371-1835-49D6-A8D6-741B9AE504DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DD17371-1835-49D6-A8D6-741B9AE504DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DD17371-1835-49D6-A8D6-741B9AE504DC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {24CAE7D4-A5F6-4CEE-BA2A-03D290ED784F} + EndGlobalSection +EndGlobal diff --git a/csharp/ExcelAddInInstaller/CustomActions/MsiSession.cs b/csharp/ExcelAddInInstaller/CustomActions/MsiSession.cs new file mode 100644 index 00000000000..e6f36343792 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/MsiSession.cs @@ -0,0 +1,136 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace Deephaven.ExcelAddInInstaller.CustomActions { + public class MsiSession { + public class NativeMethods { + public const ulong WS_VISIBLE = 0x10000000L; + + public const int GWL_STYLE = -16; + + // Declare the delegate for EnumWindows callback + public delegate bool EnumWindowsCallback(IntPtr hwnd, int lParam); + + // Import the user32.dll library + [DllImport("user32.dll")] + public static extern int EnumWindows(EnumWindowsCallback callback, int lParam); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", SetLastError = true)] + public static extern UInt32 GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("msi.dll", CharSet = CharSet.Unicode)] + public static extern uint MsiGetProperty( + int hInstall, + string szName, + StringBuilder szValueBuf, + ref uint pcchValueBuf); + + [DllImport("msi.dll", CharSet = CharSet.Unicode)] + public static extern uint MsiSetProperty(int hInstall, string szName, string szValue); + + [DllImport("msi.dll", CharSet = CharSet.Unicode)] + public static extern int MsiCreateRecord(uint cParams); + + [DllImport("msi.dll", CharSet = CharSet.Unicode)] + public static extern uint MsiRecordSetString(int hRecord, uint iField, string szValue); + + [DllImport("msi.dll", CharSet = CharSet.Unicode)] + public static extern int MsiProcessMessage(int hInstall, uint eMessageType, int hRecord); + } + + public enum InstallMessage : uint { + FATALEXIT = 0x00000000, // premature termination, possibly fatal OOM + ERROR = 0x01000000, // formatted error message + WARNING = 0x02000000, // formatted warning message + USER = 0x03000000, // user request message + INFO = 0x04000000, // informative message for log + FILESINUSE = 0x05000000, // list of files in use that need to be replaced + RESOLVESOURCE = 0x06000000, // request to determine a valid source location + OUTOFDISKSPACE = 0x07000000, // insufficient disk space message + ACTIONSTART = 0x08000000, // start of action: action name & description + ACTIONDATA = 0x09000000, // formatted data associated with individual action item + PROGRESS = 0x0A000000, // progress gauge info: units so far, total + COMMONDATA = 0x0B000000, // product info for dialog: language Id, dialog caption + INITIALIZE = 0x0C000000, // sent prior to UI initialization, no string data + TERMINATE = 0x0D000000, // sent after UI termination, no string data + SHOWDIALOG = 0x0E000000, // sent prior to display or authored dialog or wizard + } + + private IntPtr mMsiWindowHandle = IntPtr.Zero; + + private bool EnumWindowCallback(IntPtr hwnd, int lParam) { + uint wnd_proc = 0; + NativeMethods.GetWindowThreadProcessId(hwnd, out wnd_proc); + + if (wnd_proc == lParam) { + UInt32 style = NativeMethods.GetWindowLong(hwnd, NativeMethods.GWL_STYLE); + if ((style & NativeMethods.WS_VISIBLE) != 0) { + mMsiWindowHandle = hwnd; + return false; + } + } + + return true; + } + + public IntPtr MsiHandle { get; private set; } + + public string CustomActionData { get; private set; } + + public MsiSession(string aMsiHandle) { + if (string.IsNullOrEmpty(aMsiHandle)) + throw new ArgumentNullException(); + + int msiHandle = 0; + if (!int.TryParse(aMsiHandle, out msiHandle)) + throw new ArgumentException("Invalid msi handle"); + + MsiHandle = new IntPtr(msiHandle); + + string allData = GetProperty("CustomActionData"); + CustomActionData = allData.Split(new char[] { '|' }).First(); + } + + public string GetProperty(string aProperty) { + // Get buffer size + uint pSize = 0; + StringBuilder valueBuffer = new StringBuilder(); + NativeMethods.MsiGetProperty(MsiHandle.ToInt32(), aProperty, valueBuffer, ref pSize); + + // Get property value + pSize++; // null terminated + valueBuffer.Capacity = (int)pSize; + NativeMethods.MsiGetProperty(MsiHandle.ToInt32(), aProperty, valueBuffer, ref pSize); + + return valueBuffer.ToString(); + } + + public void SetProperty(string aProperty, string aValue) { + NativeMethods.MsiSetProperty(MsiHandle.ToInt32(), aProperty, aValue); + } + + public void Log(string aMessage, InstallMessage aMessageType) { + int hRecord = NativeMethods.MsiCreateRecord(1); + NativeMethods.MsiRecordSetString(hRecord, 0, "[1]"); + NativeMethods.MsiRecordSetString(hRecord, 1, aMessage); + NativeMethods.MsiProcessMessage(MsiHandle.ToInt32(), (uint)aMessageType, hRecord); + } + + public IntPtr GetMsiWindowHandle() { + string msiProcId = GetProperty("CLIENTPROCESSID"); + if (string.IsNullOrEmpty(msiProcId)) + return IntPtr.Zero; + + IntPtr handle = new IntPtr(Convert.ToInt32(msiProcId)); + mMsiWindowHandle = IntPtr.Zero; + NativeMethods.EnumWindows(EnumWindowCallback, (int)handle); + + return mMsiWindowHandle; + } + } +} diff --git a/csharp/ExcelAddInInstaller/CustomActions/Properties/AssemblyInfo.cs b/csharp/ExcelAddInInstaller/CustomActions/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..f23beb552e2 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("CustomActions")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("CustomActions")] +[assembly: AssemblyCopyright("Copyright © 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("2e432229-2429-499b-a2ab-69ab78a7eb21")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/csharp/ExcelAddInInstaller/CustomActions/README.md b/csharp/ExcelAddInInstaller/CustomActions/README.md new file mode 100644 index 00000000000..bb1b8840868 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/README.md @@ -0,0 +1,134 @@ +# Background + +The purpose of this library is to add a "Custom Action" to our +Advanced Installer package. This custom action does the actions +needed to manipulate the Windows Registry in order to do things like + +1. Detect whether the version of Office installed is 32 or 64 bit +2. Add the special keys that tell Excel to open an Excel Add-In at startup + +# Information about this library + +This library is a .NET 4.8.0 Class Library with some special boilerplate +code provided by Advanced Installer. + +.NET 4.8.0 is pretty old at this point, but it was chosen because +it is guaranteed to be present on Windows 10/11 installations. Note that +this is *not* the runtime used by the Excel Add-In itself; that add-in uses +a much more modern runtime (.NET 8). This is just the runtime used to +support the custom actions (registry manipulations) in the installer. + +This library and its boilerplate code were created by adding a +Visual Studio Extension provided by Advanced Installer to Visual Studio. +The process for adding the extension is documented here: + +https://www.advancedinstaller.com/user-guide/create-dot-net-ca.html + +The steps are: + +* Open Visual Studio and navigate to Extensions → Manage Extensions. +* In the Browse tab, search for Advanced Installer for Visual Studio + and press the Install button +* Close and reopen Visual Studio to finalize the installation. +* Now navigate to File → New → Project +* From the list of templates, you will find two very-similar looking + templates: + 1. "C# Custom Action" with description ".Net Custom Action Project + for Advanced Installer" + 2. "C# Custom Action (.NET Framework)" with description ".Net Framework + Custom Action Project for Advanced Installer" + +The difference between these two templates has to do with the +evolution of .NET. The original application framework +was called ".NET Framework" and it ran only on Windows. The modern, +cross-platform application framework is called simply .NET. +Because for our purposes here we have decided to target an old +.NET Framework version (4.8.0, see above), we choose the second +option: "C# Custom Action (.NET Framework)". + +# Windows Registry + +These are the reasons we need to access the Windows Registry + +## Determining Office bitness + +To determine the "bitness" (32 or 64) of the version of Office that is +installed, we look at this registry key: + +``` +HKEY_LOCAL_MACHINE\Software\Microsoft\Office\${VERSION}\Outlook +``` + +Notably, this information is stored at the "Outlook" part of the path, +not Excel. This key contains an entry with the name +"Bitness" and the values "x86" or "x64". + +${VERSION} refers to one of the known versions of Office, namely one of +the strings in the set 11.0, 12.0, 14.0, 15.0, 16.0 + +Version 16.0 covers Office 2016, 2019, and 2021 and Office 365, so +for Deephaven purposes we can hardcode this to 16.0 and ignore previous +versions we might find. + +As Windows evolved, the registry was divided into a 32-bit partition +and a 64-bit partition. Office itself is published in 32-bit and 64-bit versions. +The only configuration we currently support is 64-bit Office on 64-bit Windows. + +Due to this registry organization, when we look up this key programmatically, +we need to look it up in the "Registry Hive" that corresponds to the machine's +operating system bitness. This is why we have code like + +``` +var regView = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32; +var regBase = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, regView); +... +var bitnessValue = subKey.GetValue(RegistryKeys.Bitness.Name); +``` + +However, this code is needlessly general because at this time we only support 64-bit Windows. + +## Modifying the set of installed Excel Add-Ins + +The relevant registry key here is: + +``` +HKEY_CURRENT_USER\Software\Microsoft\Office\${VERSION}\Excel\Options +``` + +Again, VERSION is hardcoded to 16.0. Also, unlike the bitness step, we +don't have to write special code to pick a specific registry hive. This +is why we have code like + +``` +var subKey = Registry.CurrentUser.OpenSubKey(RegistryKeys.OpenEntries.Key, true); +``` + +This key contains zero or more entries indicating which addins Excel +should load when it starts. These entries have the following names, +which follow the almost-regular pattern: + +OPEN, OPEN1, OPEN2, OPEN3, ... + +We say "almost-regular" because the first name is OPEN when you might +expect to to be named OPEN0. + +These names must be kept dense. That is, if you delete some name that is +not at the end of the sequence, you will need to move the later entries +down to fill in the gap (e.g. the entry keyed by OPEN2 becomes OPEN1 etc). + +The value of these entries is a string that looks like the pattern + +``` +/R "${FULLPATHTOXLL}" +``` + +including the space and the quotation marks. For example, for user "kosak" the +installer would make an entry that looks like this: + +``` +/R "C:\Users\kosak\AppData\Local\Deephaven Data Labs LLC\Deephaven Excel Add-In\DeephavenExcelAddIn64.xll" +``` + +The entry created by the installer follows the above format. Of course when we +have to move the entries installed by other software (e.g. if we have to +change OPEN3 to OPEN2), we treat the values as opaque strings. diff --git a/csharp/ExcelAddInInstaller/CustomActions/RegistryKeys.cs b/csharp/ExcelAddInInstaller/CustomActions/RegistryKeys.cs new file mode 100644 index 00000000000..3cdf0cf390c --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/RegistryKeys.cs @@ -0,0 +1,16 @@ +namespace Deephaven.ExcelAddInInstaller.CustomActions { + public static class RegistryKeys { + public static class Bitness { + // Key is in HKEY_LOCAL_MACHINE + public const string Key = @"Software\Microsoft\Office\16.0\Outlook"; + public const string Name = "Bitness"; + public const string Value64 = "x64"; + public const string Value32 = "x86"; + } + + public static class OpenEntries { + // Key is in HKEY_CURRENT_USER + public const string Key = @"Software\Microsoft\Office\16.0\Excel\Options"; + } + } +} diff --git a/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs b/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs new file mode 100644 index 00000000000..18ad0de2b37 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs @@ -0,0 +1,233 @@ +using Microsoft.Win32; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Deephaven.ExcelAddInInstaller.CustomActions { + public class RegistryManager { + public static bool TryCreate(Action logger, out RegistryManager result, out string failureReason) { + result = null; + failureReason = ""; + + var regView = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32; + var regBase = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, regView); + + if (!TryOpenSubKey(regBase, RegistryKeys.Bitness.Key, false, + out var bitnessKey, out failureReason) || + !TryOpenSubKey(Registry.CurrentUser, RegistryKeys.OpenEntries.Key, true, + out var openKey, out failureReason)) { + return false; + } + + result = new RegistryManager(bitnessKey, openKey, logger); + return true; + } + + private static bool TryOpenSubKey(RegistryKey baseKey, string key, bool writable, out RegistryKey result, out string failureReason) { + failureReason = ""; + result = baseKey.OpenSubKey(key, writable); + if (result == null) { + failureReason = $"Couldn't find registry key {RegistryKeys.Bitness.Key}"; + return false; + } + + return true; + } + + + public static bool TryMakeAddInEntryFromPath(string path, out string result, out string failureReason) { + result = ""; + failureReason = ""; + if (path.Contains("\"")) { + failureReason = "Path contains illegal characters"; + return false; + } + result = $"/R \"{path}\""; + return true; + } + + private readonly RegistryKey _registryKeyForBitness; + private readonly RegistryKey _registryKeyForOpen; + private readonly Action _logger; + + public RegistryManager(RegistryKey registryKeyForBitness, RegistryKey registryKeyForOpen, + Action logger) { + _registryKeyForBitness = registryKeyForBitness; + _registryKeyForOpen = registryKeyForOpen; + _logger = logger; + } + + /// + /// Determine whether the installed version of Office is the 32-bit version or the 64-bit version. + /// It's possible for example that the 32-bit version of Office can be installed on a 64-bit OS. + /// + /// + /// + /// + public bool TryDetermineBitness(out bool is64Bit, out string failureReason) { + is64Bit = false; + failureReason = ""; + + var bitnessValue = _registryKeyForBitness.GetValue(RegistryKeys.Bitness.Name); + if (bitnessValue == null) { + failureReason = $"Couldn't find entry for {RegistryKeys.Bitness.Name}"; + return false; + } + + if (bitnessValue.Equals(RegistryKeys.Bitness.Value64)) { + is64Bit = true; + return true; + } + + if (bitnessValue.Equals(RegistryKeys.Bitness.Value32)) { + is64Bit = false; + return true; + } + + failureReason = $"Unexpected bitness value {bitnessValue}"; + return false; + } + + /// + /// The job of this method is to make whatever changes are needed so that the registry ends up in + /// the desired state. The caller can express one of two desired states: + /// 1. The caller wants the registry to end up with 0 instances of "addInEntry" + /// 2. The caller wants the registry to end up with 1 instance of "addInEntry". + /// + /// Basically #1 means "delete the key if it's there, otherwise do nothing", and + /// #2 means "add the key if it's not there, otherwise do nothing". This is true + /// except for the fact that we also do some clean-up logic... For example if there's + /// a gap between OPEN\d+ entries, we will close the gap, and if there are multiple + /// entries for "addInEntry" we will reduce the final state to whatever the caller asked + /// for (either 0 or 1 entries). + /// + /// Briefly if you want to install the addin, you can pass true for 'resultContainsAddInEntry'. + /// If you want to remove the addin, you can pass false. + /// + /// The registry value for the OPEN\d+ key. This is normally something like /R "C:\path\to\addin.xll" + /// including the space and quotation marks + /// true if you want the addInEntry present in the final result. + /// False if you want it absent from the final result + /// The human-readable reason the operation failed, if the method returns false + /// True if the operation succeeded. Otherwise, false + public bool TryUpdateAddInKeys(string addInEntry, bool resultContainsAddInEntry, out string failureReason) { + if (!TryGetOpenEntries(out var currentEntries, out failureReason)) { + return false; + } + + var resultMap = new SortedDictionary(); + foreach (var kvp in currentEntries) { + resultMap.LookupOrCreate(kvp.Item1).Before = kvp.Item2; + } + + // The canonicalization step + var allowOneEntry = resultContainsAddInEntry; + var destKey = 0; + foreach (var entry in currentEntries) { + if (entry.Item2.Equals(addInEntry)) { + if (!allowOneEntry) { + continue; + } + + allowOneEntry = false; + } + + resultMap.LookupOrCreate(destKey++).After = entry.Item2; + } + + // If there was no existing entry matching addInEntry, and the + // caller asked for it, then we still need to add it. + if (allowOneEntry) { + resultMap.LookupOrCreate(destKey).After = addInEntry; + } + + // The commit step + foreach (var entry in resultMap) { + var index = entry.Key; + var ba = entry.Value; + var valueName = IndexToKey(index); + if (ba.After == null) { + _logger($"Delete {valueName}"); + _registryKeyForOpen.DeleteValue(valueName); + continue; + } + + if (ba.Before == null) { + _logger($"Set {valueName}={ba.After}"); + _registryKeyForOpen.SetValue(valueName, ba.After); + continue; + } + + if (ba.Before.Equals(ba.After)) { + _logger($"Leave {valueName} alone: already set to {ba.Before}"); + continue; + } + + _logger($"Rewrite {valueName} from {ba.Before} to {ba.After}"); + _registryKeyForOpen.SetValue(valueName, ba.After); + } + + return true; + } + + private bool TryGetOpenEntries(out List> entries, out string failureReason) { + failureReason = ""; + entries = new List>(); + + var entryKeys = _registryKeyForOpen.GetValueNames(); + foreach (var entryKey in entryKeys) { + var value = _registryKeyForOpen.GetValue(entryKey); + if (value == null) { + failureReason = $"Entry is null for value {entryKey}"; + return false; + } + + if (!TryParseKey(entryKey, out var key)) { + continue; + } + + var svalue = value as string; + if (svalue == null) { + failureReason = $"Entry is not a string for value {entryKey}"; + return false; + } + + entries.Add(Tuple.Create(key, svalue)); + } + + return true; + } + + public static bool TryParseKey(string key, out int index) { + index = 0; + var regex = new Regex(@"^OPEN(\d*)$", RegexOptions.Singleline); + var match = regex.Match(key); + if (!match.Success) { + return false; + } + + var digits = match.Groups[1].Value; + index = digits.Length > 0 ? int.Parse(digits) : 0; + return true; + } + + public static string IndexToKey(int index) { + return index == 0 ? "OPEN" : "OPEN" + index; + } + + private class BeforeAndAfter { + public string Before; + public string After; + } + } +} + +static class ExtensionMethods { + public static V LookupOrCreate(this IDictionary dict, K key) where V : new() { + if (!dict.TryGetValue(key, out var value)) { + value = new V(); + dict[key] = value; + } + return value; + } +} diff --git a/csharp/ExcelAddInInstaller/ExcelAddInInstaller.aip b/csharp/ExcelAddInInstaller/ExcelAddInInstaller.aip new file mode 100644 index 00000000000..c4940030ac1 --- /dev/null +++ b/csharp/ExcelAddInInstaller/ExcelAddInInstaller.aip @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/csharp/ExcelAddInInstaller/TestCustomActions/App.config b/csharp/ExcelAddInInstaller/TestCustomActions/App.config new file mode 100644 index 00000000000..3916e0e4b4a --- /dev/null +++ b/csharp/ExcelAddInInstaller/TestCustomActions/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/csharp/ExcelAddInInstaller/TestCustomActions/Program.cs b/csharp/ExcelAddInInstaller/TestCustomActions/Program.cs new file mode 100644 index 00000000000..6cafe5a119f --- /dev/null +++ b/csharp/ExcelAddInInstaller/TestCustomActions/Program.cs @@ -0,0 +1,17 @@ +using System; +using System.Diagnostics; + +namespace Deephaven.ExcelAddInInstaller.CustomActions { + public class Program { + static void Main(string[] args) { + Action logger = Console.WriteLine; + if (!RegistryManager.TryCreate(logger, out var oem, out var failureReason) || + !oem.TryUpdateAddInKeys("zamboni 666", false, out failureReason)) { + logger($"Sad because {failureReason}"); + return; + } + + logger("HAPPY"); + } + } +} diff --git a/csharp/ExcelAddInInstaller/TestCustomActions/Properties/AssemblyInfo.cs b/csharp/ExcelAddInInstaller/TestCustomActions/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..28caa89a675 --- /dev/null +++ b/csharp/ExcelAddInInstaller/TestCustomActions/Properties/AssemblyInfo.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("TestCustomActions")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("TestCustomActions")] +[assembly: AssemblyCopyright("Copyright © 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("8dd17371-1835-49d6-a8d6-741b9ae504dc")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/csharp/ExcelAddInInstaller/TestCustomActions/TestCustomActions.csproj b/csharp/ExcelAddInInstaller/TestCustomActions/TestCustomActions.csproj new file mode 100644 index 00000000000..bc0cc763758 --- /dev/null +++ b/csharp/ExcelAddInInstaller/TestCustomActions/TestCustomActions.csproj @@ -0,0 +1,59 @@ + + + + + Debug + AnyCPU + {8DD17371-1835-49D6-A8D6-741B9AE504DC} + Exe + TestCustomActions + TestCustomActions + v4.8 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + {2e432229-2429-499b-a2ab-69ab78a7eb21} + CustomActions + + + + \ No newline at end of file diff --git a/csharp/ExcelAddInInstaller/certificates-public/README.md b/csharp/ExcelAddInInstaller/certificates-public/README.md new file mode 100644 index 00000000000..aca8ef41810 --- /dev/null +++ b/csharp/ExcelAddInInstaller/certificates-public/README.md @@ -0,0 +1,11 @@ +The deephaven.cer file contains Deephaven's public key. Like all .cer files +it contains only a public key (and not a private key). It therefore does not +contain any secrets and does not need to be protected. + +This file can be obtained by exporting the certificate from an installer +built by Advanced Installer (yes, that is a self-referential process), or +by exporting it from the Windows Certificate manager (certmgr.msc). + +We store this file here so that (with the user's permission) Advanced Installer +can install the Deephaven public key in the local user's "Trusted Publishers", +which in turn allows Excel to trust the Deephaven Excel AddIn. diff --git a/csharp/ExcelAddInInstaller/certificates-public/deephaven.cer b/csharp/ExcelAddInInstaller/certificates-public/deephaven.cer new file mode 100644 index 00000000000..31cafb88d8b Binary files /dev/null and b/csharp/ExcelAddInInstaller/certificates-public/deephaven.cer differ diff --git a/csharp/ExcelAddInInstaller/dhinstall/.gitignore b/csharp/ExcelAddInInstaller/dhinstall/.gitignore new file mode 100644 index 00000000000..8620bdd7442 --- /dev/null +++ b/csharp/ExcelAddInInstaller/dhinstall/.gitignore @@ -0,0 +1,2 @@ +*.dll +*.exe diff --git a/csharp/ExcelAddInInstaller/dhinstall/README.md b/csharp/ExcelAddInInstaller/dhinstall/README.md new file mode 100644 index 00000000000..5fcd4f28b93 --- /dev/null +++ b/csharp/ExcelAddInInstaller/dhinstall/README.md @@ -0,0 +1,9 @@ +This file is a placeholder that creates this directory in source control. + +This directory is the install location (e.g. DHINSTALL) for the +Community Core dll's and the Enterprise Core Plus dll's +that get built for the installer. + +We put the build output for those builds here so they can be easily found +by the installer, and they exist at a short relative location from the +Advanced Installer project itself.