Microsoft 365 Apps Click-to-Run COM Interface in PowerShell

Gears from a pocket watch movement.

Overview

Tale as old as time

At some point in the somewhat distant past, a group of engineers at Microsoft got together, rolled a blunt, and said to themselves, "You know how everyone hates Windows Updates? Well we do too. Brothers and sisters it is time to unshackle the Microsoft Office platform from its update prison! No longer will we accept the bondage of publishing multiple updates monthly. Let us create a new platform where the entire application suite is updated at one time! Let us create utopia!"

I'm not saying they were wrong. Some of the best music was conceived in a similar, less than legal manner. However, at some point later the filthy systems administrators came back into the picture and said, "Dudes and dudettes. This is cool and all, but you know we'd really still like a way to deliver the content from our own servers and manage when these updates get applied to our devices. Not that we don't trust you or anything - I mean your track record is absolutely flawless - it's just... well, you know, our bosses... and stuff."

And thus, the Office Click-To-Run COM Interface was born.

The OfficeC2RCOM Interface serves as way for manageability applications (MEMCM, Intune, Tanium, etc.) to instruct OfficeClickToRun.exe:

  1. When to download updates
  2. Where to download updates from
  3. What updates to download
  4. When to install updates

Of course this is only half of the battle. Nobody wants to have yet another platform to manage these questions, so updates were yet again published to the dreaded Microsoft Update Catalog / WSUS. However, unwilling to publish all bits and bytes necessary to cumulatively update an Office installation to the Microsoft Update CDN, the engineers took a different approach. The content would continue to be delivered from the Office CDN, but metadata would be published to Microsoft Update / WSUS with instructions that could be consumed by a manageability application to answer questions 2 and 3 from above.

How does it work?

I'm going to provide less detail here for simplicity sake - and we're going to focus on how this works in a MEMCM+WSUS style environment. The basic steps are:

  1. The appropriate M365 Apps updates are synchronized from Microsoft Update into WSUS. These are separated by channel, architecture, and version.
  2. This update contains metadata in the "More information" property of the update. Specifically a link to an API that informs the manageability application from where it can get the content necessary to update Office (if it wishes to store and deliver the content itself rather than the Office CDN).
  3. The manageability client downloads and stages this content in its distribution servers (or not... depending on configuration).
  4. Admins approve the update for specific machines.
  5. When the machine checks in for updates it determines applicability of the Office update as it would any other Windows update
  6. If the update is applicable - instead of installing the update like a Windows update, the manageability application then kicks back in and makes a call to the OfficeC2RCOM Interfaces to download the content from its own distribution channels or from the Office CDN.
  7. Once the content is downloaded, the Office client then presents a ribbon to the end user notifying them that an update is ready to be installed but Office apps must be closed to do so. It is also possible for the manageability application to force the "Apply" action here, but doing so requires the Office apps to be closed in 99% of cases. Depending on the configuration of the manageability application it may wait or it may force the apps to close.
  8. Presto, chango... Office is updated!

This is summarized in the following flowchart I stole borrowed from Microsoft:

M365 Client Update Workflow
M365 Client Update Workflow - https://docs.microsoft.com/en-us/office/client-developer/shared/manageability-applications-with-the-office-365-click-to-run-installer#microsoft-365-apps-updates

Time to do stupid stuff

Those of you who know me know that occasionally often I like to write code and scripts just to prove that I can do something even if I don't need to do something. Part of me just likes to see how things work by making them work in unconventional or unnecessary ways. So I asked myself (never a good place to start), could I manage an Office installation the same way that MEMCM manages it without MEMCM?

The answer to this question is yes, although the path to get there was painful.

The documentation for this interface is "available" albeit limited and frankly lacking: Integrating manageability applications with Microsoft 365 Apps Click-to-Run installer

The methods we're interested in are:

  • Apply(string Parameters): Applies/installs updates based on the parameters sent to the method.
  • Download(string Parameters): Downloads updates based on the parameters sent to the method.
  • Status([out]UpdateStatusReport): Outputs the current state of the COM object (downloading, downloaded, applying, applied, etc.)
  • GetBlockingApps([out]string BlockingApps): Outputs the blocking applications that would keep an install from completing, comma-separated

COM Objects
The first thing we have to tackle is COM Objects. Now if we write a PowerShell script to interface with the Windows Update Agent, for example, we have an easy path to do this - because it publishes a well-known Program Identifier (Microsoft.Update.Session) and this object has well defined methods/interfaces:

Windows Update Agent COM Object Methods

However, the Office Click-To-Run COM Object doesn't. Wading through the documentation, you eventually come across a GUID that could be used to create the COM object: {52C2F9C2-F1AC-4021-BF50-756A5FA8DDFE}. So we can do some creative work to create this object (UpdateNotifyObject2) in PowerShell by borrowing some .NET methods:

1$clsid = [guid]::new("52C2F9C2-F1AC-4021-BF50-756A5FA8DDFE")
2$type = [Type]::GetTypeFromCLSID($clsid)
3$object = [Activator]::CreateInstance($type)
4$object | Get-Member

Unfortunately, when we look at the output of the above we don't have any useful methods. It's basically just the default junk for a ComObject. This is where my knowledge of COM is lacking, but I believe this is because the object (UpdateNotifyObject2) doesn't publish any methods, and while those method definitions are contained in the interface (IUpdateNotify2), we don't have a definition of this interface to cast to the object. In case you're confused, don't worry... it's about to get even more confusing if you don't have any background or experience in C#. It's about to get even more confusing because I'm probably not even using the correct terminology.

C# and InteropServices
Now we have to break out the big guns. Essentially what we have to do is make some calls to "unmanaged" code (code that runs outside of the CLR - COM objects) through the use of InteropServices. You may have come across this before when trying to do something like make a window full size. You add some "C# Code" in a here string, and then call the "Add-Type" cmdlet in PowerShell to "compile" the code and add those namespaces/classes to the PowerShell instance.

The key pieces we have to define are:

  1. Our using statements (System and System.Runtime.InteropServices)
  2. Importing and creating a definition for the IUpdateNotify2 interface. An interface is like a contract - it says "what you need to do" without defining "how" you do it. An interface might say "bool IsThisTrue(bool Value)", but the implementation (the "how") would include the code necessary to perform the work of that method.
  3. Importing and creating a class for UpdateNotifyObject2. This is the "object" that we'll work with.
  4. Creating the enumerations and structs that are required by the interface.
  5. Creating a class to expose the functions. This part may seem odd, but for whatever reason, I could not cast the UpdateNotifyObject2 as an IUpdateNotify2 type in PowerShell directly. This also ends up making it a bit easier to work with in PowerShell, so I'm not complaining.
  6. The instructions in the documentation say that you should create a new instance of the COM object for every method call you make because it times out after a short period of time. Creating a new instance will look first to see if there is an existing instance and use that, otherwise it will create a new instance. We take this into consideraiton in the class that we create in step 5.

I'm not going to walk through each of these steps individually - you can see the code at the end of this post, and I'm happy to answer questions about it in the comments.

"Compiling" C# Code in PowerShell
As I said earlier, you may have done this before. The basic steps are to create a here string with the code that we just wrote, and call the "Add-Type" cmdlet to "compile" the code and make the namespace/classes available to us in the PowerShell session.

1$code = @"
2   <our fancy c# code>
3"@
4Add-Type -TypeDefinition $code -Language CSharp

We should now be able to access the code we created by calling it how we'd call any other .NET Namespace/Class/Method:

[_Namespace_._Class_]::Method(parameters)

Using our ComObject

Now if you "borrow" the code that I put at the end of this post, the namespace is "OfficeC2RCom", the class we'll primarily focus on is "COMObject", and the methods are "Download", "Apply", "GetBlockingApps", and "GetCOMObjectStatus"

Download(string parameters)
This is arguably the most important method we want to call. This method handles the download of the content from the Office CDN, and takes a single string parameter. This parameter accepts:

  • displaylevel: (true/false) to show the installation status (although in my testing it doesn't actually display anything for download)
  • updatetoversion: (16.0.xxxxx.yyyyy) this is the important one - we can pass a specific version to update/downgrade to
  • updatebaseurl: not used for the time being as we don't have a download source other than the Office CDN
  • downloadsource: not used for the time being as we don't have our own BITS manager
  • contentid: not used for the time being as we don't have our own BITS manager

This string should be formatted like: "displaylevel=false updatetoversion=16.0.xxxxx.yyyyyy" where it's parameter=value pairs separated by spaces.

We call this by executing the following line after we add the type:

1[OfficeC2RCom.COMObject]::Download("updatetoversion=16.0.xxxxx.yyyyy")

GetCOMObjectStatus()
This is the second most important command. We can only call the Download and Apply methods when the COMObject is in a specific state. These acceptable states are defined in the docs.

We can use this method to determine the current state and decide whether or not we can proceed, or if the method we called (like Download) is complete. We call this by executing the following line:

1[OfficeC2RCom.COMObject]::GetCOMObjectStatus()

This outputs JSON, so you can pipe the output to ConvertFrom-JSON, to have an object to work with. You'll have both the "exit code" of the current state and the name of the current state.

Apply(string parameter)
This method is only recommended if you plan on monitoring for the status of Office executables and takes a single parameter. This parameter accepts:

  • displaylevel: (true/false) to show the installation status
  • forceappshutdown: (true/false) this will forceably shutdown the Office apps even if the user is actively using them... not recommended

Call this with the following code:

1[OfficeC2RCom.COMObject]::Apply("displaylevel=false forceappshutdown=false")

GetBlockingApps()
This method outputs a string array of the running applications that would block Office from updating if you call the Apply method without the forceappshutdown parameter set to true.

Call this with the following code:

1[OfficeC2RCom.COMObject]::GetBlockingApps()

An "Example"
You might utilize this code in a script like this to download, monitor, and apply the updates:

 1[OfficeC2RCom.COMObject]::Download("updatetoversion=16.0.15225.20370")
 2
 3$downloading = [OfficeC2RCom.COMObject]::GetCOMObjectStatus() | ConvertFrom-JSON
 4while($downloading.status -eq "eDOWNLOAD_WIP"){
 5  Start-Sleep -Seconds 1
 6  $downloading = [OfficeC2RCom.COMObject]::GetCOMObjectStatus() | ConvertFrom-JSON
 7}
 8
 9if($downloading.status -eq "eDOWNLOAD_SUCCEEDED"){
10  [OfficeC2RCom.COMObject]::Apply("displaylevel=true forceappshutdown=true")
11}
12
13$installing = [OfficeC2RCom.COMObject]::GetCOMObjectStatus() | ConvertFrom-JSON
14while($installing.status -eq "eAPPLY_WIP"){
15  Start-Sleep -Seconds 1
16  $installing = [OfficeC2RCom.COMObject]::GetCOMObjectStatus() | ConvertFrom-JSON
17}
18
19Write-Output "Download and Installation Complete? Last known status: $($installing.status)"

Admittedly this is a pretty poor and not well thought out example, but you get the picture I hope.

I don't care about fluff, give me the code

If you read all the way through to get here, I'm impressed. If you clicked the link at the top to get here - well, I don't blame you. The latest version of my code is available on my GitHub repo here: OfficeC2RCom.ps1

I've also pasted it here for posterity and SEO, but this may not be as up to date as the GitHub repo:

  1$OfficeCOM = @"
  2using System;
  3using System.Runtime.InteropServices;
  4
  5namespace OfficeC2RCom
  6{
  7    [ComImport]
  8    [Guid("90E166F0-D621-4793-BE78-F58008DDDD2A")]
  9    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
 10    internal interface IUpdateNotify2
 11    {
 12        [return: MarshalAs(UnmanagedType.U4)]
 13        uint Download([MarshalAs(UnmanagedType.LPWStr)] string pcwszParameters);
 14
 15        [return: MarshalAs(UnmanagedType.U4)]
 16        uint Apply([MarshalAs(UnmanagedType.LPWStr)] string pcwszParameters);
 17
 18        [return: MarshalAs(UnmanagedType.U4)]
 19        uint Cancel();
 20
 21        [return: MarshalAs(UnmanagedType.U4)]
 22        uint status(out UPDATE_STATUS_REPORT pUpdateStatusReport);
 23
 24        [return: MarshalAs(UnmanagedType.U4)]
 25        uint GetBlockingApps(out string AppsList);
 26
 27        [return: MarshalAs(UnmanagedType.U4)]
 28        uint GetOfficeDeploymentData(int dataType, string pcwszName, out string OfficeData);
 29
 30    }
 31
 32    [ComImport]
 33    [Guid("52C2F9C2-F1AC-4021-BF50-756A5FA8DDFE")]
 34    internal class UpdateNotifyObject2 { }
 35
 36    [StructLayout(LayoutKind.Sequential)]
 37    internal struct UPDATE_STATUS_REPORT
 38    {
 39        public UPDATE_STATUS status;
 40        public uint error;
 41        [MarshalAs(UnmanagedType.BStr)] public string contentid;
 42    }
 43
 44    internal enum UPDATE_STATUS
 45    {
 46        eUPDATE_UNKNOWN = 0,
 47        eDOWNLOAD_PENDING,
 48        eDOWNLOAD_WIP,
 49        eDOWNLOAD_CANCELLING,
 50        eDOWNLOAD_CANCELLED,
 51        eDOWNLOAD_FAILED,
 52        eDOWNLOAD_SUCCEEDED,
 53        eAPPLY_PENDING,
 54        eAPPLY_WIP,
 55        eAPPLY_SUCCEEDED,
 56        eAPPLY_FAILED
 57    }
 58
 59    internal enum UPDATE_ERROR_CODE
 60    {
 61        eOK = 0,
 62        eFAILED_UNEXPECTED,
 63        eTRIGGER_DISABLED,
 64        ePIPELINE_IN_USE,
 65        eFAILED_STOP_C2RSERVICE,
 66        eFAILED_GET_CLIENTUPDATEFOLDER,
 67        eFAILED_LOCK_PACKAGE_TO_UPDATE,
 68        eFAILED_CREATE_STREAM_SESSION,
 69        eFAILED_PUBLISH_WORKING_CONFIGURATION,
 70        eFAILED_DOWNLOAD_UPGRADE_PACKAGE,
 71        eFAILED_APPLY_UPGRADE_PACKAGE,
 72        eFAILED_INITIALIZE_RSOD,
 73        eFAILED_PUBLISH_RSOD,
 74        // Keep this one as the last
 75        eUNKNOWN
 76    }
 77
 78    public static class COMObject
 79    {
 80        static IUpdateNotify2 updater;
 81        static UPDATE_STATUS_REPORT report;
 82
 83        public static uint Download(string parameters = "")
 84        {
 85            updater = (IUpdateNotify2)new UpdateNotifyObject2();
 86            return updater.Download(parameters);
 87        }
 88
 89        public static uint Apply(string parameters = "")
 90        {
 91            updater = (IUpdateNotify2)new UpdateNotifyObject2();
 92            return updater.Apply(parameters);
 93        }
 94
 95        public static string GetCOMObjectStatus()
 96        {
 97            updater = (IUpdateNotify2)new UpdateNotifyObject2();
 98            updater.status(out report);
 99
100            return "{ \"status\":\"" + report.status + "\", \"result\":\"" + report.error + "\"}";
101        }
102
103        public static string[] GetBlockingApps()
104        {
105            string blockingApps;
106
107            updater = (IUpdateNotify2)new UpdateNotifyObject2();
108            updater.GetBlockingApps(out blockingApps);
109            return blockingApps.Split(',');
110        }
111    }
112}
113"@
114Add-Type -TypeDefinition $OfficeCOM -Language CSharp