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 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.