0

This is quite a long story so let me begin with what my goal is here:

The company I work for has its own reporting software which generates reports in MS Excel, this is done using C# and the normal Excel interop (powered by the Excel COM interface). Recently, a client of ours asked if we could implement a setting to choose what Excel version the program uses to generate reports and this sent me down a massive rabbit hole...

I have spent the last three days reading up on this so I think I understand the issue fairly well by now: all Excel versions use the very same CLSID for their COM Object. Therefore, I am unable to create a specific version of it.

What I've tried:

I have seen people spinning up an Excel process of the required version manually and then iterating through all of its windows to find a specific one that provides a COM Object. I have implemented this as a proof of concept already but it's honestly kind of janky (especially with newer versions of Excel) and I would like to use something a little more robust. Ideally, I would like to just use COM and it's internal mechanisms to spin up an Excel instance.

After reading up on COM I figured out how it decides which instance of Excel to start up when an object is requested:
HKCR\Classes\CLSID\{00024500-0000-0000-C000-000000000046}\LocalServer32 just contains a path to the executable.

(From poking around with Sysinternals ProcessMonitor it seems like it also checks HKCU\Software\Classes\CLSID\{00024500-0000-0000-C000-000000000046}\LocalServer32 first which is interesting)

At first I tried to just copy and paste the Excel entry and give it a new, unique CLSID so that i have a way of accessing it. This obviously did not work as the COM server will try to find the given CLSID within the Excel executable and wont find my newly assigned one.

My second Idea was to set up a temporary Registry redirect using RegOverridePredefKey. Redirecting HKEY_CURRENT_USER to HKEY_CURRENT_USER\Software\<company>\<program>\Sandbox and then put an exact copy of the Excel COM entry (with the original CLSID) in there to then be able to change the path to whatever I'd like. Sadly, this did not work. I set up the redirect and COM just straight up ignored it, even tho the Registry requests came from the same thread and process... enter image description here

So this is where I got stuck. I could try to create/overwrite a COM Object entry in HKCU but I wouldn't want to change the client's Registry like that. Ideally, I'd use a temporary redirect like what I've tried.

I have read a few articles of people using COM components without registering them first. Would something like that work here? Would that even work with out-of-proc servers like Excel? Would it be possible to run an Excel server without registering it first or would it cause issues with Excel being already registered in the Registry?

I'm a bit at a loss here so I hope some Windows experts can help me out here! Cheers!

RedCube
  • 25
  • 4
  • In theory, the COM object's progid mechanism specifies the version you want to use, so for example `CLSID` key under `Excel.Application.14` tells you the clsid for Excel 14, `CLSID` key under `Excel.Application.16` tells you the clsid for Excel 16, etc. And `CurVer` key from `Excel.Application` tells you the current progid to use (note Excel.Sheet.5 is different than Excel.Sheet) If they're all the same it's because Microsoft wants them to for compat reasons. In this case using real .exe path and work through it is probably the only way. – Simon Mourier Apr 25 '23 at 06:07

1 Answers1

0

You can start Excel by specifying the concrete path of the .exe file. In one of my applications I did:

var process = Process.Start(appInfo.ExePath, arguments);
for (int tries = 0; tries < 100; tries++) {
    Thread.Sleep(100);
    IAppWrapper<TApplication, TDocument> app = GetOpenInstance(sourceDocOrDatabase);
    if (app is not null) {
        app.Hwnd = process.MainWindowHandle;
        return app;
    }
}

(The IAppWrapper interface is my own type. It encapsulates the different Office applications. But since you need only Excel, you don't need it.)

GetOpenInstance works like this (where Exl is defined as using Exl = Microsoft.Office.Interop.Excel;):

public override IAppWrapper<Exl.Application, Exl.Workbook> GetOpenInstance(string sourceDocOrDatabase)
{
    try {
        object obj = RunningObjectTable.GetRunningCOMObjectByName(sourceDocOrDatabase);
        // See: https://stackoverflow.com/a/23146559/880990
        if (obj is Exl.Workbook { Application: Exl.Application xl } wbk) {
            return new Excel { App = xl, Doc = wbk };
        }
    } catch (Exception ex) {
        Dialogs.Info($"Excel encountered problems ....\r\n\r\n{ex.Message}");
    }
    return default;
}

It uses this static class:

using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;

namespace CySoft.RibbonPro;

public static class RunningObjectTable
{
    [DllImport("ole32.dll")]
    private static extern int GetRunningObjectTable(uint reserved, out IRunningObjectTable pprot);

    [DllImport("ole32.dll")]
    private static extern int CreateBindCtx(uint reserved, out IBindCtx pctx);

    public static object GetRunningCOMObjectByName(string objectDisplayName)
    {
        IRunningObjectTable runningObjectTable = null;
        IEnumMoniker monikerList = null;
        try {
            if (GetRunningObjectTable(0, out runningObjectTable) != 0 || runningObjectTable is null) {
                return null;
            }
            runningObjectTable.EnumRunning(out monikerList);
            monikerList.Reset();
            var monikerContainer = new IMoniker[1];
            IntPtr pointerFetchedMonikers = IntPtr.Zero;
            while (monikerList.Next(1, monikerContainer, pointerFetchedMonikers) == 0) {
                _ = CreateBindCtx(0, out IBindCtx bindInfo);
                monikerContainer[0].GetDisplayName(bindInfo, null, out string displayName);
                Marshal.ReleaseComObject(bindInfo);
                if (displayName.IndexOf(objectDisplayName, StringComparison.OrdinalIgnoreCase) != -1) {
                    runningObjectTable.GetObject(monikerContainer[0], out object comInstance);
                    return comInstance;
                }
            }
        } catch {
            return null;
        } finally {
            if (runningObjectTable is not null) Marshal.ReleaseComObject(runningObjectTable);
            if (monikerList is not null) Marshal.ReleaseComObject(monikerList);
        }
        return null;
    }
}

And this is how I get a list of all the installed Excel versions (again some types here are my own types):

public IList<AppVersionInfo> GetInstalledApps(AppTypeInfo appTypeInfo, bool onlyWithFluentUserInterface = false)
{
    var appInfos = new List<AppVersionInfo>();

    // Example 64 bit Windows:
    //     HKLM\SOFTWARE\WOW6432Node\Microsoft\Office\16.0\Excel\InstallRoot
    // Example 32 bit Windows:
    //     HKLM\SOFTWARE\Microsoft\Office\16.0\Excel\InstallRoot

    string officeRegKey = Environment.Is64BitOperatingSystem ? officeRegKey64 : officeRegKey32;
    RegistryKey[] hkRootRegKeys = { Registry.CurrentUser, Registry.LocalMachine };

    foreach (RegistryKey hkRegKey in hkRootRegKeys) {
        using RegistryKey officeKey = hkRegKey.OpenSubKey(officeRegKey);
        if (officeKey is not null) {
            foreach (var versionString in officeKey.GetSubKeyNames()) {
                if (_versionNumberRegex.IsMatch(versionString) &&
                    (!onlyWithFluentUserInterface || Decimal.TryParse(versionString, out var ver) && ver >= 12.0m)) {

                    string appSubKeyName = $@"{versionString}\{appTypeInfo.RegistryKey}{installRootRegKeySuffix}";
                    using RegistryKey pathKey = officeKey.OpenSubKey(appSubKeyName);
                    if (pathKey?.GetValue("Path") is string path) {
                        if (!path.EndsWith(@"\")) {
                            path += @"\";
                        }
                        var appInfo = new AppVersionInfo(
                            $"{AppVersionInfo.GetVersionedName(appTypeInfo.Name, versionString)}",
                            $"{path}{appTypeInfo.ExecutableName}");
                        appInfos.Add(appInfo);
                    }
                }
            }
        }
    }

    return appInfos;
}
Olivier Jacot-Descombes
  • 104,806
  • 13
  • 138
  • 188
  • Thanks a lot for your answer! I have actually tried something similar to this but forgot to mention it in my original question. The main problem I had was not being able to get the instance of excel I wanted when there is already one running (it would always return the first instance). Does your method have something in place to make sure you really get the instance which youre starting? – RedCube Apr 24 '23 at 13:28
  • I do not have additional tests. Maybe you could get a list of all open instances of Excel before and after starting a new instance and then compare the two lists to find the new instance. – Olivier Jacot-Descombes Apr 24 '23 at 13:52