0

I have a .net 6.0 MVVM WPF application that is used to walk users through an onboarding workflow. I now need to interface with this application from a legacy external process that was written in VBScript. My assumption is that this could be achieved with a COM object. I need to be able to miniplate the WPF application from the exposed COM object, like increment a counter, display a status message. I have seen Microsoft EXEs do this, registering themselves as a COM object at runtime, and then unregistering when exited.

While I'm pretty well versed in c# and web based development, I've never touched COM and have spend the last 5 days reading and trying to wrap my head around its capabilities to map my requirements back to a specific COM implementation. I'm struggling. Most of the more comprehensive explanations and walkthroughs are now pushing 20 years old and capabilities are clearly different between .net 4.7 and (core) 6.0. Based on my reading, I think what I need to implement is an "out-of-process" EXE. But how this then manifests as a consumable COM object for VB is where it all breaks down. I feel like this is where "marshaling" a "proxy" come in, but its still over my head.

My initial attempts in .net 6.0 all failed, so I went back to 4.7 just to see if I could mimic the desired behavior and wrap my head around it. Starting with a POC:

    [ComVisible(true)]
    [InterfaceType(ComInterfaceType.InterfaceIsDual)]
    [Guid("337FF551-2BDA-47B3-B5EE-6DE8171012A2")]
    public interface ITestCom
    {
        void runtest();
    }

    [ComVisible(true)]
    [Guid("5615E7EA-6F89-4474-AFD6-2DD4E52996F6")]
    [ClassInterface(ClassInterfaceType.None)]
    [ProgId("TestCom.Tester")]
    public class TestCom : ITestCom
    {
        public void runtest()
        {
            Debug.WriteLine("Hello World.");
            Console.WriteLine("Test Complete");
        }

    }

Which yielded a EXE that could be registered with regasm, but then acted like a .net Library in that there was no interaction between the EXE (server?) when running and a vbs script which instantiated the object. So, thinking out loud here, this ultimately is an in-process implementation, which doesn't even require the exe to be running. And when looking at the CLSID in the registry doesn't list a LocalServer32 key.

The most informative explanation of COM I've found has been this article which was written in 2006 and of course is referencing .net 4.7. Reading it, I'm without enough perspective to know what is still relevant and if more straight forward implementations have been adopted in the last 17 years.
https://www.codeproject.com/Articles/12579/Building-COM-Servers-in-NET

While my ultimate goal is to implement extensibility via a COM object for my .net core WPF, at this point I need to know if I'm even on the right path. If that means a POC in 4.7 as a starting point, I'll take it. I have looked at many code samples on github, and from MS articles and struggled to find a simple implementation of what I hope to accomplish I have seen other information on StackOverflow that maybe suggests that this was a pattern that was only exposed using ActiveX.Net, which feels like another can of worms.

I'd be grateful for any help.

NiteFiddle
  • 5
  • 1
  • 3
  • 1
    You can refer to this sample https://github.com/dotnet/samples/tree/main/core/extensions/OutOfProcCOM – Serg Jul 05 '23 at 17:13
  • @Serg I have seen this example. I actually downloaded the c++ toolkit and compiled it last night and its still on my desktop. After registering the EXEserver, a CLSID is created in the registry but with only a LocalServer32 key. I have been unable to connect to the server with VBS. Manually creating a ProgID Key didn't work, nor attempting to create the object with the syntax CreateObject("clsid:"). Ideas? – NiteFiddle Jul 05 '23 at 17:44
  • can you talk named pipes? – T McKeown Jul 05 '23 at 17:52
  • https://stackoverflow.com/questions/13806153/example-of-named-pipes – T McKeown Jul 05 '23 at 17:52
  • @NiteFiddle I played with this sample back in the net5 days. I was able to build and register the out-of-process exe server written in C# (`ExeServer` folder) and then consume it from a C# client. Unfortunately, I do not remember what bitness (32 vs 64) I was using during these tests. On the other hand, the bitness mismatch should *not* be a problem for out-of-process COM interop. So I would try to use the server from the example with a client written in C# (also from the sample). And after that works, I would try to use the same server from VBS and debug VBS-specific problems. – Serg Jul 05 '23 at 17:54
  • I would keep it simple.. why not just create a TCP Listener on the localhost? I posted a link to use named pipes also. But if you need to talk to a different machine then just build a dedicated listener for this. Not ideal I know, but could do the job. – T McKeown Jul 05 '23 at 17:56
  • Another question related to the example mentioned above https://stackoverflow.com/questions/74349860/64bit-c-sharp-net-wrapper-for-32bit-c-sharp-net-dll/74370522#74370522 – Serg Jul 05 '23 at 18:01
  • isn't that all using remoting? – T McKeown Jul 05 '23 at 18:04
  • even if you add a COM+ server class.. you have to manage the lifecycle, the connections etc.. – T McKeown Jul 05 '23 at 18:04
  • what is the end goal? to increment a number you said? – T McKeown Jul 05 '23 at 18:05
  • @TMcKeown Looking at named pipes now. I wasn't aware that there was interoperability with pipes in VBs. I'm surprised to see that the supplied example is using FileSystemObject as the interface, which would seem to limit the implementation as everything is string. I guess then maybe passing JSON makes sense and deserialize back to an object on the C# server side. I've never done anything with pipes either, so its back to square one, researching the foundation and an events based implementation, and I'm kind of running out of time. – NiteFiddle Jul 05 '23 at 19:58
  • @TMcKeown the end goal is inter-process communication on a local device. One being a .net 6 WPF and the other being a legacy VBScript (which is massive: to big to rebuild as managed code in the time allotted). Communication needs to be only one direction, VBs -> WPF. I could have been done with this, if I resigned to just poll registry keys, for changes, but I was trying to use the right tool for the job, and I thought COM was that tool. – NiteFiddle Jul 05 '23 at 20:10
  • @Serg I also was able to consume the server from the Managed client project in the solution. But that implementation is using coCreateInstance to generate an instance of the COM server object. Not something I can do in VBS. – NiteFiddle Jul 05 '23 at 20:16

2 Answers2

1

There are complex ways with COM, CLSIDs, IIDS, registry registration, proxies, interfaces, the whole shebang... but I will show you an easier way using the Running Object Table ("ROT")

Here is a sample WPF app that contains all what you need, pure C# code, no registration needed.

The app registers your live object in the ROT. You can check it's there using this ROTVIEW tool (run it as x64 if your process/OS is x64)

This is how your live object registration appears

enter image description here

Here is the VBScript to call it (make sure the WPF app is running)

Set app = GetObject("x:\somepath")
WScript.Echo app.ToUpperCase("hello")

The path here is artificial it doesn't need to point to a real file, it's just so VBScript can pick it. Outputs:

enter image description here

And here is the WPF app code:

using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Windows;
using FILETIME = System.Runtime.InteropServices.ComTypes.FILETIME;

namespace MyWpfApp
{
    public partial class MainWindow : Window
    {
        private readonly int _cookie;
        private readonly MyComObject _myComObject;

        public MainWindow()
        {
            InitializeComponent();

            // register my object, the path is just to be able to get it from VBS
            _myComObject = new();
            RunningObjectTable.RegisterFile(_myComObject, @"x:\somepath");
        }

        protected override void OnClosed(EventArgs e)
        {
            base.OnClosed(e);
            // revoke the object
            if (_cookie != 0)
            {
                RunningObjectTable.Revoke(_cookie);
            }
        }
    }

    [ComVisible(true)]
    // VBScript only knows IDispatch
    [ClassInterface(ClassInterfaceType.AutoDispatch)]
    public class MyComObject
    {
        // add any public method here
        // note you are limited to COM automation types
        public string? ToUpperCase(string? text) => text?.ToUpper();
    }

    public static partial class RunningObjectTable
    {
        public static int RegisterFile(object instance, string filePath, ROTFLAGS flags = ROTFLAGS.ROTFLAGS_REGISTRATIONKEEPSALIVE)
        {
            Marshal.ThrowExceptionForHR(GetRunningObjectTable(0, out var table));
            Marshal.ThrowExceptionForHR(CreateFileMoniker(filePath, out var mk));
            Marshal.ThrowExceptionForHR(table.Register(flags, instance, mk, out var cookie));
            return cookie;
        }

        public static void Revoke(int cookie)
        {
            Marshal.ThrowExceptionForHR(GetRunningObjectTable(0, out var table));
            table.Revoke(cookie);
        }

        [DllImport("ole32")]
        private static extern int GetRunningObjectTable(int reserved, out IRunningObjectTable pprot);

        [DllImport("ole32", CharSet = CharSet.Unicode)]
        private static extern int CreateFileMoniker(string lpszPathName, out IMoniker ppmk);

        [ComImport, Guid("00000010-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
        private interface IRunningObjectTable
        {
            [PreserveSig]
            int Register(ROTFLAGS grfFlags, [MarshalAs(UnmanagedType.Interface)] object punkObject, IMoniker pmkObjectName, out int pdwRegister);
            [PreserveSig]
            int Revoke(int dwRegister);
            [PreserveSig]
            int IsRunning(IMoniker pmkObjectName);
            [PreserveSig]
            int GetObject(IMoniker pmkObjectName, [MarshalAs(UnmanagedType.Interface)] out object ppunkObject);
            [PreserveSig]
            int NoteChangeTime(int dwRegister, ref FILETIME pfiletime);
            [PreserveSig]
            int GetTimeOfLastChange(IMoniker pmkObjectName, out FILETIME pfiletime);
            [PreserveSig]
            int EnumRunning(out IEnumMoniker ppenumMoniker);
        }
    }

    [Flags]
    public enum ROTFLAGS
    {
        ROTFLAGS_NONE = 0,
        ROTFLAGS_REGISTRATIONKEEPSALIVE = 1,
        ROTFLAGS_ALLOWANYCLIENT = 2,
    }
}
Simon Mourier
  • 132,049
  • 21
  • 248
  • 298
  • The only potential problem here is that the WPF app will has to be launched manually before any interop is possible, am I right? There might also be a problem with marshalling of complex custom types, but I'm not sure. Maybe if all these types are somehow registered at runtime everything will work. Thanks for this solution, I learned something new today! – Serg Jul 06 '23 at 12:08
  • 1
    @serg - yes, with my code the app has to be launched priori to calling it. As for marshalling of complex types, VBScript only supports OLE Automation types (VARIANT, etc. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-oaut/7b5fa59b-d8f6-4a47-9695-630d3c10363e), so there's no problem. If you use a COM client capable of handling other types (C/C++, Delphi, .NET) and these types needs proxy / stubs then yes you would need these proxy/stubs to be registered. But even for these clients, using IDispatch is well supported and relatively simple to work with. – Simon Mourier Jul 06 '23 at 12:51
  • @SimonMourier This is super interesting - and its simplicity is enough to make me reconsider the full COM implementation. It also finally connected the dots for me related to this MS sample, which I had previously reviewed during my research. https://github.com/dotnet/runtime/issues/10525 Thanks for this alternative solution! – NiteFiddle Jul 06 '23 at 15:41
  • @NiteFiddle - this sample code won't work for VBScript as VBScript's GetObject implementation, as far as I know, doesn't support custom *item moniker*. That's while I used a *file moniker*. Plus VBScript won't support non-IDispatch interface like this IServer. Plus this sample still needs a tlb – Simon Mourier Jul 06 '23 at 17:22
0

The answer is based on the example project from the https://github.com/dotnet/samples/tree/main/core/extensions/OutOfProcCOM

Steps:

  • download example

  • modify as described in 64bit C# .Net Wrapper for 32bit C# .Net DLL (update to net6 and so on)

  • additionally set <PlatformTarget>x64</PlatformTarget> for the ExeServer.csproj (or leave x86, but use the SysWOW64 registry key below. And don't use SysWOW64 when you will going to change registration code as Windows performs transparent redirection))

  • build ExeServer as described in the readme

  • register ExeServer /regserver (from Administrator command prompt)

  • import the following reg file into the registry. This will associate a ProgId with our exe server

    Windows Registry Editor Version 5.00
    
    [HKEY_CLASSES_ROOT\ExeServer.Application]
    @="ExeServer.Application"
    
    [HKEY_CLASSES_ROOT\ExeServer.Application\CLSID]
    @="{AF080472-F173-4D9D-8BE7-435776617347}"
    

    Please note that guid is exactly the same as value of the Contract.Constants.ServerClass constant. The ProgId ExeServer.Application may be whatever you want.

  • run the following VBS

    MsgBox(CreateObject("ExeServer.Application").ComputePi())
    

    and get the message box with value of pi.

  • do not forget to unregister the test server with ExeServer /unregserver

Manually adding registry keys is just a quick hack to test the idea. For real solution you need to modify the COMRegistration.LocalServer.Register and COMRegistration.LocalServer.Unregister code to add/delete such a key.

Serg
  • 3,454
  • 2
  • 13
  • 17
  • 1
    Thank you for spelling out the registry items, this was the missing piece. I was able to take pieces from the sample code, change all the guids and integrate it successfully into my project. The wizardry of importing the Server.Contract.props project to automate the tlb copy was all new to me and took a minute to figure out. But its all working as expected. Thanks again! – NiteFiddle Jul 06 '23 at 06:13
  • Just noticed that this example, while working with VBS, does not work if attempting to create the com with powershell ($o = New-Object -ComObject "ExeServer.Application"). Powershell doesn't think the Type Library is registered even though the tlb file is being processed by OleAut32.LoadTypeLibEx. Why does this not expose the com uniformly? Is there something inherently different about how vbscript finds com interfaces vs Powershell? – NiteFiddle Jul 28 '23 at 21:14
  • Sorry, I don't know how exactly the function call is done in PS and how it differs from the vbs. The documentation says that PS uses net framework callable wrappers, so at first glance it should work. But it doesn't. You can try adding more registration information to the registry as described in https://learn.microsoft.com/en-us/windows/win32/com/registering-com-applications and https://learn.microsoft.com/en-us/windows/win32/com/com-registry-keys, but I'm not sure here. – Serg Jul 28 '23 at 22:48