Freigeben über


Click-Once forced updates in VSTO II: A fuller solution

 

Last Week I talked about user-generated updates and the Click-Once Deployment Management API's.  Based on some internal feedback and a large degree of personal interest, I'm going to come right back around to the same topic, but with a cleaner solution that does the whole kit and caboodle and does it nicely.  While certainly I have put a lot more investigation into this method, the disclaimer still stands. 

This scenario is not fully tested and strictly speaking is not supported.

That said, here are the features I am implementing:

A customized document that...

  • Checks to see if there is and update on the Server
  • Reports the Current Version
  • Reports the Version on the Server
  • Specifies the Click-Once Cache Path (for debugging purposes, I needed to verify information)
  • Specifies the Document Path (for debugging purposes I needed to see the string result, you'll see why later)
  • Updates the Solution "on demand"
  • Restarts the Application and Re-Opens the Document to ensure the most recently installed update is being executed.
  • Maintains a certain degree of "safety" around debugging (occasionally I forget and hit F5).

So there's the list, pretty lengthy, but I wanted to be clear about what my goals were.  I spent about 6 or so hours working through this, mostly because I wanted to do something a little more robust then my usual fare.  As a result I ran into some issues that are worth noting:

In last week's example, the solution I used I was deploying to a local UNC share; in this example I deploy to a local path.  One of the more "interesting" results was that I ended up dealing with paths that had spaces in it.  This is particularly interesting because... application arguments are split on the space character.  The basic lesson here should be:  If you are dealing with paths in a scenario like this it is probably better to use URI's and enforce absolute URI's so you don't have some of the issues that come from special characters.

I also had to spend to much time determining what using statements I needed to enable the API enabling trust code.  As a result, I will post those statements:

 using System.Security;
using System.Security.Permissions;
using System.Security.Policy;
using System.Deployment.Application;

Finally:  Reporting is important, I generally use Message Box because I'm lazy and don't want to write real error-handling code for these investigations.  What I'm finding though is the more complicated stuff I write, the more information I end up packing into the messages to aid in debugging.  I fully recommend if you do any these things out in the wild you ensure you're using really good reporting tools.  The more information you can shove into logging when things go wrong, the more likely you'll be able to figure out what happens.  This is particularly important when you do stuff like spinning out separate processes to do things, those processes are particularly hard to capture during debugging so verbose reporting is likely to save you a lot of time.

That said, let's get back to the actual project, or actually in this case 2 projects.  Last week I mentioned the need of restarting the customization after the update finishes.  I don't personally know of any inherent support for doing so, but that doesn't make it impossible to do.  It is possible there is a better way, but this is what I came up with based upon a week of mulling it over while also being very very busy.  This solution achieves the restart by shipping a executable that handles the re-opening the document.  I call this this tool "Word Restarter" because amazingly enough it restarts word.

Word Restarter

What I did was create a Managed Console app, and then set it to be a Windows Application.  This creates a program that runs very lightweight with no UI (at least not any that I don't explicitly supply) that I can now pass just enough information to be very useful.

Here's the code for this console app:

 //used in exception cases and debugging.
foreach (string s in args)
    argAsString = argAsString + "[" + s + "]"; 

try
{
    
    //Missing is used because C# does not have optional parameters
    Object missing = System.Type.Missing;

    //Check to see if any existing Word Processes exist
    System.Diagnostics.Process[] currentWordProcesses = 
        System.Diagnostics.Process.GetProcessesByName("WINWORD");

    //Empty Case gets ignored, lazy but effective enough
    foreach (System.Diagnostics.Process p in currentWordProcesses) p.WaitForExit();

    //Could be more, but I'm being restrictive because I want to 
    //make sure it fails quickly
    if (args.Length != 1) throw new Exception("You must Specify a full path to a" + 
      " valid Word Document to use this program.");

    string path = args[0] as string;

    //Path Doesn't handle URI's.
    //if (System.IO.Path.IsPathRooted(path) == false)
    //    throw new Exception("You must Specify a full path to a " +
    //    "valid Word Document to use this program.");

    Word.ApplicationClass wordApp = new Microsoft.Office.Interop.Word.ApplicationClass();
    wordApp.Visible = true;

    //cast the path to an object (Office PIA's operate on Objects)
    object docpath = (object)path;
    wordApp.Documents.Open(ref docpath, ref missing, ref missing,
                           ref missing, ref missing, ref missing,
                           ref missing, ref missing, ref missing,
                           ref missing, ref missing, ref missing,
                           ref missing, ref missing, ref missing,
                           ref missing);
}
catch (Exception e)
{
    System.Windows.Forms.MessageBox.Show(
        "The following Exception Occured " +
        "when trying to restart the Word Process: " + 
        e.Message + "\n\n Stack: \n" + e.StackTrace + 
        "Args("+args.Length+"): \n" + argAsString);
}

It could certainly be better, it's not super robust, but it should be just enough to give you an idea on what is necessary to accomplish the task.  There are some pretty serious flaws in this specific implementation, I am basically assuming that all Word Processes will go away, but the code I call in the customization does not explicitly close-all documents.  It's possible to kill all version of the word process but.  I leave it to you to think  about this further I just want to point out that this still isn't production quality.

Once I was fairly confident I had this working I built a retail build of this exe and moved on to the document.

Self Updating Doc

Really this should be "User Updating Document" but it doesn't really matter too much, it's a name.   Anyway, this project is fairly simple and is very similar to the one I created last week.  The key piece of this project is, I'm only using one method to update the document, but I'm doing it in the way I felt last week would be ideal.

So here are some (path obfuscated) images of the Document in action:

"Initial" Run:

UpdatingDoc

Published a "new" update and then Clicked "Check For Update".  The document was still running from the Desktop this entire time:

UpdatingDocAfterCheck

Clicked Update, watched Word disappear (clicking through my debugging information dialogs) and finally word comes back up with:

UpdatingDocAfterUpdate

While I do obscure the paths for security reasons, I did want to point out that the Cache Path changes.  This is particularly of note because it should help explain why an update can happen while the customization is still executing.  That said, I haven't spent a lot of time investigating the data caching behavior that occurs, so make sure you fully test any side-by-side / error conditions / resource handling scenarios that might occur if you decide to implement this in production code.

So what does the code look like?

Well...during start-up I setup the trust so I can access Click-Once API features.  I would recommend isolating these calls to those times this functionality is called by the user to simply reduce the work that happens during startup, but in my case startup time is effective enough.  Then I check some initial values and fill the information in the labels:

 if(ApplicationDeployment.IsNetworkDeployed)
{
    Assembly addinAssembly= Assembly.GetExecutingAssembly();
    CachePath = addinAssembly.CodeBase.Substring(0, addinAssembly.CodeBase.Length - 
        System.IO.Path.GetFileName(addinAssembly.CodeBase).Length);

    CurrentDep = ApplicationDeployment.CurrentDeployment;
    string deploymentFullName = CurrentDep.UpdatedApplicationFullName;
    ApplicationIdentity appID = new ApplicationIdentity(deploymentFullName);
    PermissionSet everything = new PermissionSet(PermissionState.Unrestricted);

    ApplicationTrust trust = new ApplicationTrust(appID);
    trust.DefaultGrantSet = new PolicyStatement(everything);
    trust.IsApplicationTrustedToRun = true;
    trust.Persist = true;

    ApplicationSecurityManager.UserApplicationTrusts.Add(trust);

}

Here's the "Helper" Function I call after setting the trust statement to check the deployment location.

 private void CheckForUpdate()
{
    if (ApplicationDeployment.IsNetworkDeployed)
    {

        if (CurrentDep.CheckForUpdate())
        {
            UpdateCheckInfo updateInfo = CurrentDep.CheckForDetailedUpdate();
            ClickOnceVersion = updateInfo.AvailableVersion;
        }
        else
        {
            ClickOnceVersion = CurrentDep.CurrentVersion;
        }

        label1.Text = "Current Version: " + CurrentDep.CurrentVersion.ToString();
        label2.Text = "Deployed Version: " + ClickOnceVersion.ToString();
        label3.Text = "CachePath = " + CachePath;
        label4.Text = "DocumentPath = " + Globals.ThisDocument.Path + 
            "\\" + Globals.ThisDocument.Name;

    }
    else
    {
        string notClickOnce = "This Customization is not ClickOnce Installed.";

        label1.Text = "Current Version: " + notClickOnce;
        label2.Text = "Deployed Version: " + notClickOnce;
        label3.Text = "CachePath = " + notClickOnce;
        label4.Text = "DocumentPath = " + notClickOnce;
    }
}

In "real" Solution I would just have a single button that you click to check for the update and that proceed to download the update only if the version is a new version (You simply need to call "CheckForUpdate" unless you want to report Version info).  In the case of my document, I just call the CheckForUpdate helper function when the Check for Update button as way of helping me validate the state appropriately.

And Finally the updating code:

 if (ApplicationDeployment.IsNetworkDeployed)
{

    Uri DocPath = new Uri(Globals.ThisDocument.Path + "\\" + Globals.ThisDocument.Name);
    Uri InstallerPath = new Uri("C:\\Program Files\\Common Files\\microsoft shared\\VSTO\\9.0\\VSTOINSTALLER.exe");
    Uri RestarterPath = new Uri(CachePath + "WordRestarter.exe");
    Uri Updatelocation = new Uri(CurrentDep.UpdateLocation.ToString());

    DialogResult dResult = MessageBox.Show(
        "Are you sure you want to continue?\n" +
        "Updating will require a Restart of Word,\n" +
        "Cancel now or forever hold your peace",
        "Update Requested", MessageBoxButtons.OKCancel);

    if (DialogResult.OK == dResult)
    {
        //Call VSTOInstaller Explicitely in "Silent Mode"
        Process VstoInstallerProc = new System.Diagnostics.Process();
        VstoInstallerProc.StartInfo.Arguments = " /S /I " + Updatelocation.AbsoluteUri;
        VstoInstallerProc.StartInfo.FileName = InstallerPath.AbsoluteUri;
        VstoInstallerProc.Start();

        //Call VSTOInstaller Explicitely in "Silent Mode"
        Process RestarterProc = new System.Diagnostics.Process();
        RestarterProc.StartInfo.Arguments = DocPath.AbsoluteUri;
        RestarterProc.StartInfo.FileName = RestarterPath.AbsoluteUri;
        RestarterProc.Start();

        VstoInstallerProc.WaitForExit();
        if (VstoInstallerProc.ExitCode == 0)
            MessageBox.Show("Update was succesfull, restarting..");
        else
            MessageBox.Show("Update failed: Exit Code (" + VstoInstallerProc.ExitCode.ToString() + ")");

        object save = (object)false;
        this.Application.Quit(ref save, ref missing, ref missing);
    }
}

Ideally you would determine the Installer Path using Environment Variables or some other more robust method, I just wanted to get this working so I've hard-coded the path.  You'll notice that I am using the Uri class here to handle the paths, this turns out to be a much more robust method for ensuring the paths are well-formed when you pass them off to the Process Class.

Let's go back to the WordRestarter for a second.  You'll notice that I'm using reflection to figure out the location of the cache and then subsequently using that path to get to the executable.  You're probably wondering how it got into the cache.  To put it simply, I shipped it as part of the Click-Once package.  To do this I added the executable file as a content file that is always copied.  Here's what it looks like in VS:

UpdatingDocRestarterVSSettings

If you are worried about the security implication of using this method, I want to point out a couple of very relevant facts:

  • The executable hash is injected into the Click-Once Deployment Manifest.
  • Tampering of the Executable on the server would cause the update to be rejected.
  • The manifest is signed with a Certificate that helps guarantee that the user trusts either the specific publisher or the user trusts the specific solution at a specific location. 

With that I'm going to finish this particular post (that's 2 long ones in 2 weeks). 

Thank You for reading.

Kris

Comments

  • Anonymous
    June 29, 2010
    Hi Kristopher, I am running into a strange issue. I am developing a Outlook 2007 addin using Visual Studio 2010. I would like to check if there are any updates and if so prompt the user to restart outlook.  However if I call ApplicationDeployment.CheckForDetailedUpdate or ApplicationDeployment.CheckForUpdate I get the following exception: DependentPlatformMissingException: Unable to install or run the application. The application requires that assembly Microsoft.Vbe.Interop.Forms Version 11.0.0.0 be installed in the Global Assembly Cache (GAC) first. This happens both on my dev machine and on the remote machine. Here's what I've tried so far with no success:
  • Uninstalled and reinstalled Office 2007 PIAs
  • Added Microsoft.Vbe.Interop.Forms v 11.0.0.0 (file version 12.*) as a reference to my project
  • Verified that the dll with the correct version is in my GAC I have no idea why this exception is occurring. Hope you can help. Thanks Matt
  • Anonymous
    June 29, 2010
    I believe you are being hit by a combination of targetting 4.0'sPIA embedding combined with a potential bug in how the manifests end up being generated.  Doest the Microsoft.Vbe.Interop.Forms DLL get included in the xml of the application maniest (dll.Manifest file)?  If so, I think the workaround is to explicitly set it to copy-always = false in the project settings. This is just a rough, guess, I'd need to see your output manifest and project file to get a better sense of what is actually happening.

  • Anonymous
    August 24, 2010
    I have been dealing with the exact same issue.  Your ideas are interesting.  Would love to find out if Matt back on June 30th ended up having success w/ your suggestion, or what his solution was.

  • Anonymous
    August 24, 2010
    You should be able to tell by looking at the .dll.manifest file.  If it contains any information about the interop files, then you're running into the issue I mentioned above.  And the work around should be good enough. I believe we fixed this behavior before RTM, but I've been focused on completely different stuff for the last 4 months so it's become "dim and hazy ancient memory".   I don't think Matt ever responded back which may mean that he was fixed by my recommendation (or found some other solution).

  • Anonymous
    August 27, 2012
    Hello Kris, First of all, would you still suggest using this method (a lot of things could have happened in the last 4 years!) ? If so, would it still work if the user of my VSTO application doesn't have an administrator account? The part where you modify trust informations is the one I'm worried about. Thanks

  • Anonymous
    August 27, 2012
    I haven't worked on VSTO for about 2 years, but to the best of my knowledge this is still valid.  Certainly I would sit down and try this out.  The sample code provided here gives you a pretty good starting point with all of the code you should need.  Feel free to copy any of it to try out the methodology. You shouldn't need admin privileges to set the trust level of your application at runtime, you just need to be running under "Full Trust" which is a requirement for VSTO apps anyway.  ClickOnce is still user-level only so the only "admin" privileges you should need would be at runtime installation (ie: Installing the VSTO or .NET runtimes).

  • Anonymous
    September 25, 2013
    Hi Kris, It seems that this is still the only way to perform the equivalent of ApplicationDeployment.Update in a VSTO app, but I was wondering if there is a way to retrieve the progress of the install/download? In WPF we can use ApplicationDeployment.ProgressChanged to update a progress bar, which is useful when large apps are being deployed over a slow connection, is it at all possible to retrieve some sort of progress (kb, or %, etc.) from the VSTO Installer?