From db830e6bf5e02a99158620debe5fa55b498f6832 Mon Sep 17 00:00:00 2001 From: Corey Kosak Date: Fri, 18 Oct 2024 14:58:22 -0400 Subject: [PATCH 1/3] feat(csharp): A Windows Installer for the Deephaven Excel Add-In --- csharp/.gitignore | 1 + csharp/ExcelAddIn/ExcelAddIn.csproj | 6 + csharp/ExcelAddInInstaller/.gitignore | 2 + .../CustomActions/CustomActions.cs | 58 +++ .../CustomActions/CustomActions.csproj | 58 +++ .../CustomActions/CustomActions.sln | 31 ++ .../CustomActions/MsiSession.cs | 136 +++++++ .../CustomActions/Properties/AssemblyInfo.cs | 36 ++ .../CustomActions/README.md | 121 ++++++ .../CustomActions/RegistryKeys.cs | 16 + .../CustomActions/RegistryManager.cs | 234 ++++++++++++ .../ExcelAddInInstaller.aip | 357 ++++++++++++++++++ .../TestCustomActions/App.config | 6 + .../TestCustomActions/Program.cs | 17 + .../Properties/AssemblyInfo.cs | 33 ++ .../TestCustomActions.csproj | 59 +++ .../certificates-public/README.md | 11 + .../certificates-public/deephaven.cer | Bin 0 -> 1780 bytes .../ExcelAddInInstaller/dhinstall/.gitignore | 2 + .../ExcelAddInInstaller/dhinstall/README.md | 9 + 20 files changed, 1193 insertions(+) create mode 100644 csharp/ExcelAddInInstaller/.gitignore create mode 100644 csharp/ExcelAddInInstaller/CustomActions/CustomActions.cs create mode 100644 csharp/ExcelAddInInstaller/CustomActions/CustomActions.csproj create mode 100644 csharp/ExcelAddInInstaller/CustomActions/CustomActions.sln create mode 100644 csharp/ExcelAddInInstaller/CustomActions/MsiSession.cs create mode 100644 csharp/ExcelAddInInstaller/CustomActions/Properties/AssemblyInfo.cs create mode 100644 csharp/ExcelAddInInstaller/CustomActions/README.md create mode 100644 csharp/ExcelAddInInstaller/CustomActions/RegistryKeys.cs create mode 100644 csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs create mode 100644 csharp/ExcelAddInInstaller/ExcelAddInInstaller.aip create mode 100644 csharp/ExcelAddInInstaller/TestCustomActions/App.config create mode 100644 csharp/ExcelAddInInstaller/TestCustomActions/Program.cs create mode 100644 csharp/ExcelAddInInstaller/TestCustomActions/Properties/AssemblyInfo.cs create mode 100644 csharp/ExcelAddInInstaller/TestCustomActions/TestCustomActions.csproj create mode 100644 csharp/ExcelAddInInstaller/certificates-public/README.md create mode 100644 csharp/ExcelAddInInstaller/certificates-public/deephaven.cer create mode 100644 csharp/ExcelAddInInstaller/dhinstall/.gitignore create mode 100644 csharp/ExcelAddInInstaller/dhinstall/README.md 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..9fa71224cc5 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/README.md @@ -0,0 +1,121 @@ +# 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 I chose it because (I believe) +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 + +Basically the steps are: + +* Open Visual Studio and navigate to Extensions → Manage Extensions. +* In the Online section, search for Advanced Installer for Visual Studio +* In Visual Studio navigate to File → New Project +* From the list of templates, select the C# Custom Action template or the + C# Custom Action (.NET Framework) template, depending on your needs + +Because of the above compatibility requirements I have decided that +the right version is "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 +``` + +And yes, this information is stored at the "Outlook" part of the path, +not Excel. This key contains an entry with the name +which contains the name "Bitness" and the values "x86" or "x64". + +When I say ${VERSION} I mean one of the known versions of Office, 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. + +Note that for reasons 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); +``` + +Apparently, the Bitness key for a 32 bit installation of Office will still +be in the 64 bit registry "hive" on a 64 bit machine. We may need to look +into this further if it turns out to matter. + +## 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, ... + +I 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. On my computer the value of OPEN is currently + +``` +/R "C:\Users\kosak\Desktop\exceladdin-v7\ExcelAddIn-AddIn64-packed.xll" +``` + +The fact that I have installed my addin on the Desktop is not a best practice. The point here is to show the syntax. + +We take care to make our entry follow the above format. Of course when we are moving +the entries installed by other people, we treat them as opaque strings and don't look +at the values. 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..3afb7accfd8 --- /dev/null +++ b/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs @@ -0,0 +1,234 @@ +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 0000000000000000000000000000000000000000..31cafb88d8b3c871d493397744c0e4d33d98bfd7 GIT binary patch literal 1780 zcmXqLV*6mw#ByT+GZP~dlK}gvB|1ybr_KM`9ML{2@R*zdFB_*;n@8JsUPeZ4RtAGi zLv903Hs(+kHesgFU_)^OQ4ohqn9n6MJ<~b0s6GcQ@s(9ysiB*83f0h3S&DJm^4 zNlj63H&JlTPf1k>&P>nC%u81Y3U)LxuryN$_HZ<|Fi|ivFfvqdb~H4Q6X!JoN*G!g zfI$?HYiejk7cXkuK194?Hk49rc8 z{R}|yE~X~NMuv?$B69XzQD|~Z-ZmrZgm<5+sLPGlZuPv|IhLlK>3E$i_(V?j$0v=b zvi$8T=g-RPOs&`DZ^{ZX+E^^Ra+ZbrT;{#&p1MumvWTmgecH~~LF{KXO|PnLGU#Q> z67{LvQQZD0|N4)&*%l}IrrH+9TK%<+Z#`ohtMp1GyXf4Mr8BZ$e3j`8{j=u@Py551 z>b6@~6-0$DZ}fL>e3H3fiBW1qf|sPm#bld?EbomMt4!h&9j>+fo13lsim!H8`r&}9 z{hubJT&kQZWaIR1eW113?K>*xa@QyV-m2TvIp%yc&uzb*#h0eD^G=19X|UG%BHb7sUE*7fObC*4lS?&cDG`dgXR-A?9Hr92Ze zBLm~&CMITJjQlr{2gbLoGK++PSc6E0`GeQntbfD{w*CH>Sk2Lq`o+)5Ko+Eck420{ zq#*s@@TDd^Y>cd| z>`aVe(itTs1y=g{<>lpiDZq@749p06$@#hZ&H=#&d?5Y&jEw(TSeTjE8w`X&d{q`6 z11^{e%*+OjTS21oERAao8dn(vF9=-V-{uQ7HMuCq7}Z!=XaB1%@6g~@;cIims#Fy$;qs zKb@i_u59eF-C}mLlIKR!jh81f726ye8#>~41h^TMGcmt(3ykpKc5%@=y~5V+g_7)F z|Lu#6m(D+bsN_oBiEE1N=e;uW1wVcb&5l)C7<;dxGvpm}@?1e%^9Iwl#4lOrIrX0| z$>jAqEpj{e|E%Do&&4Z$eTYWaVIhoZDn zj(P2oUnnLaw0TSN?!U|L{9gOybgz26$>FF2d8NO4?{%hYuKM2v?J2)FUO7GY zY~%|$mIo|K;;u?ctGRASE8r89@_x72ZnogpbC-Uy$4FNGa%Q@i{+TQP=LxZgJK7}^ zxg563u2x^?pYo4yg4=Yr+b*WU!k3h_jwG+WzUbc{-ot-Q7q_;3p24r1KmW literal 0 HcmV?d00001 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. From fe7eb9c191846e6a317f44fedca4c3f8a5ddf387 Mon Sep 17 00:00:00 2001 From: Corey Kosak Date: Tue, 19 Nov 2024 23:44:25 -0500 Subject: [PATCH 2/3] Respond to review feedback --- .../CustomActions/README.md | 69 +++++++++++-------- .../CustomActions/RegistryManager.cs | 1 - 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/csharp/ExcelAddInInstaller/CustomActions/README.md b/csharp/ExcelAddInInstaller/CustomActions/README.md index 9fa71224cc5..708e216362d 100644 --- a/csharp/ExcelAddInInstaller/CustomActions/README.md +++ b/csharp/ExcelAddInInstaller/CustomActions/README.md @@ -4,7 +4,7 @@ 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 +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 @@ -12,7 +12,7 @@ needed to manipulate the Windows Registry in order to do things like 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 I chose it because (I believe) +.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 @@ -24,16 +24,27 @@ The process for adding the extension is documented here: https://www.advancedinstaller.com/user-guide/create-dot-net-ca.html -Basically the steps are: +The steps are: * Open Visual Studio and navigate to Extensions → Manage Extensions. -* In the Online section, search for Advanced Installer for Visual Studio -* In Visual Studio navigate to File → New Project -* From the list of templates, select the C# Custom Action template or the - C# Custom Action (.NET Framework) template, depending on your needs - -Because of the above compatibility requirements I have decided that -the right version is "C# Custom Action (.NET Framework)". +* 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, Windows-only 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 @@ -48,19 +59,23 @@ installed, we look at this registry key: HKEY_LOCAL_MACHINE\Software\Microsoft\Office\${VERSION}\Outlook ``` -And yes, this information is stored at the "Outlook" part of the path, +Notably, this information is stored at the "Outlook" part of the path, not Excel. This key contains an entry with the name -which contains the name "Bitness" and the values "x86" or "x64". +"Bitness" and the values "x86" or "x64". -When I say ${VERSION} I mean one of the known versions of Office, one of +${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. -Note that for reasons when we look up this key programmatically, we need -to look it up in the "Registry Hive" that corresponds to the machine's +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 ``` @@ -70,9 +85,7 @@ var regBase = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, regView); var bitnessValue = subKey.GetValue(RegistryKeys.Bitness.Name); ``` -Apparently, the Bitness key for a 32 bit installation of Office will still -be in the 64 bit registry "hive" on a 64 bit machine. We may need to look -into this further if it turns out to matter. +However, this code is needlessly general because at this time we only support 64-bit Windows. ## Modifying the set of installed Excel Add-Ins @@ -91,16 +104,17 @@ 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: +should load when it starts. These entries have the following names, +which follow the almost-regular pattern: OPEN, OPEN1, OPEN2, OPEN3, ... -I say "almost-regular" because the first name is OPEN when you might +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). +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 @@ -108,14 +122,13 @@ The value of these entries is a string that looks like the pattern /R "${FULLPATHTOXLL}" ``` -including the space and the quotation marks. On my computer the value of OPEN is currently +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\Desktop\exceladdin-v7\ExcelAddIn-AddIn64-packed.xll" +/R "C:\Users\kosak\AppData\Local\Deephaven Data Labs LLC\Deephaven Excel Add-In\DeephavenExcelAddIn64.xll" ``` -The fact that I have installed my addin on the Desktop is not a best practice. The point here is to show the syntax. - -We take care to make our entry follow the above format. Of course when we are moving -the entries installed by other people, we treat them as opaque strings and don't look -at the values. +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/RegistryManager.cs b/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs index 3afb7accfd8..18ad0de2b37 100644 --- a/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs +++ b/csharp/ExcelAddInInstaller/CustomActions/RegistryManager.cs @@ -64,7 +64,6 @@ public RegistryManager(RegistryKey registryKeyForBitness, RegistryKey registryKe /// /// /// - public bool TryDetermineBitness(out bool is64Bit, out string failureReason) { is64Bit = false; failureReason = ""; From ac3531ce3ebe2d5ca26a16bf9990cdc4af173db7 Mon Sep 17 00:00:00 2001 From: Corey Kosak Date: Tue, 19 Nov 2024 23:51:43 -0500 Subject: [PATCH 3/3] remove redundancy --- csharp/ExcelAddInInstaller/CustomActions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/ExcelAddInInstaller/CustomActions/README.md b/csharp/ExcelAddInInstaller/CustomActions/README.md index 708e216362d..bb1b8840868 100644 --- a/csharp/ExcelAddInInstaller/CustomActions/README.md +++ b/csharp/ExcelAddInInstaller/CustomActions/README.md @@ -39,7 +39,7 @@ The steps are: Custom Action Project for Advanced Installer" The difference between these two templates has to do with the -evolution of .NET. The original, Windows-only application framework +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