Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

ClickOnce application autostart and clean uninstall or the way to customize ClickOnce installation

4.88/5 (24 votes)
8 Mar 2013CPOL5 min read 147.1K   6.3K  
Approach of light install/uninstall customizations for ClickOnce, including autostart and clean up on uninstall.

Introduction

ClickOnce is a good technology, but in some cases it can be hardly customizable. Recently I had a requirement for a ClickOnce application to launch on start up. Sounds simple, right? But not for ClickOnce. You can Google and find that it is or was a real problem for some developers. Even MSDN in comparison chart (MSI vs. ClickOnce) says that launch on start up is not supported by ClickOnce deployment, also it has limitations in Registry access.

In this article I will show one of the approaches to solving this issue. It may have some disadvantages, but is reliable and can be used in some apps. 

Functionality

This solution allows you to: 

  • Add a shortcut to your application in the startup group, so it will be launched on startup
  • Delete shortcut from startup group on uninstall
  • Clean up whatever you want to clean up on uninstall
  • Auto close application on uninstall (actually the process is killed, but you can add some messaging and ask your program to close)
  • Set icon you like in Application Wizard instead of default one
  • Set some help and about links for your application in the Application Wizard

main window

Main application

Image 2

Startup shortcut

Startup shortcut

Custom icon and links in Application Wizard

Image 4

Custom uninstall dialog for ClickOnce

Using the code

Note: The first time, build the solution in Release at least once, there's a link in the main project ("uninstall.exe" in the Release folder should exist).

The solution consists of three projects:

  • WPF application, application we want to deliver
  • Common library, which has ClickOnceHelper
  • Uninstall application, launched on uninstall

Both the WPF application and the Uninstall application reference the Common library. The WPF application is the one that is published, so we need it to include the actual Uninstall executable. For this purpose the WPF project depends on the Uninstall project, so uninstall.exe appears before the main application build. To be able to include in deployment uninstall.exe in deploymentI added a link to uninstall.exe from the Release directory. (I built uninstall.exe in release then: left click on the main project -> Add existing item -> Find uninstall.exe in the release directory -> Chose "Add link" from the Add button drop down). So if you have no uninstall.exe in the Release directory the main project won't build. Build once in Release mode to debug/compile. 

Main applicaiton

The noteworthy code is in the App class:

C#
protected override void OnStartup(StartupEventArgs e)
{
    try
    {
        var clickOnceHelper = new ClickOnceHelper(Globals.PublisherName, Globals.ProductName);
        clickOnceHelper.UpdateUninstallParameters();
        clickOnceHelper.AddShortcutToStartup();
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex);
    }
    base.OnStartup(e);
} 

On every launch, registry parameters for the application and shortcut are checked. If it is the first time or there was an update, registry values will be updated and an icon will be created if deleted. If everything is OK, the code does almost nothing. 

Uninstall application  

It is a simple application. It has a mutex to check for single instance and code to perform an uninstall. 

The code shows a Y/N dialog. If the user chooses to uninstall the application, the code will automate ClickOnce uninstall with ClickOnceHelper and will perform other custom actions you wish. In this case the application data folder is removed from AppData, which is supposed to be used by the main application. 

C#
if (MessageBox.Show(Resources.Uninstall_Question, Resources.Uninstall + 
   Globals.ProductName, MessageBoxButtons.YesNo) == DialogResult.Yes)
{
    var clickOnceHelper = new ClickOnceHelper(Globals.PublisherName, Globals.ProductName);
    clickOnceHelper.Uninstall();

    //Delete all files from publisher folder and folder itself on uninstall
    var publisherFolder = Path.Combine(Environment.GetFolderPath(
        Environment.SpecialFolder.LocalApplicationData), Globals.PublisherName);
    if (Directory.Exists(publisherFolder))
        Directory.Delete(publisherFolder, true);
} 

Also Windows compatibility files named like setup*, install*, uninstall* may result in an app crash, this is because Windows is not sure if these "setup"s are finished properly. To avoid this possible app crash, embed a custom manifest and uncomment the supported OS GUIDs:

Image 5

ClickOnceHelper 

The core of the functionality is in this class. I separated it into three regions:

  • Shortcut related
  • Update registry
  • Uninstall

To use this class you need to provide the publisher name and product name. They should match values from Publish->Options values in the main project. You can provide them in the config, hard code, or figure out at runtime. In these solutions they are hard coded in the static Clobals class.  

On creation it creates directory in application data to store original uninstall string: 

C#
var publisherFolder = Path.Combine(Environment.GetFolderPath(
		    Environment.SpecialFolder.LocalApplicationData), PublisherName);
if (!Directory.Exists(publisherFolder))
    Directory.CreateDirectory(publisherFolder);
UninstallFile = Path.Combine(publisherFolder, UninstallStringFile); 

Also it looks for the uninstall section (registry key) of the application in Registry: 

C#
UninstallRegistryKey = GetUninstallRegistryKeyByProductName(ProductName); 

Shortcut code:  

C#
private string GetShortcutPath()
{
    var allProgramsPath = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
    var shortcutPath = Path.Combine(allProgramsPath, PublisherName);
    return Path.Combine(shortcutPath, ProductName) + ApprefExtension;
}

private string GetStartupShortcutPath()
{
    var startupPath = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
    return Path.Combine(startupPath, ProductName) + ApprefExtension;
}

public void AddShortcutToStartup()
{
    if (!ApplicationDeployment.IsNetworkDeployed)
        return;
    var startupPath = GetStartupShortcutPath();
    if (File.Exists(startupPath))
        return;
    File.Copy(GetShortcutPath(), startupPath);
}

private void RemoveShortcutFromStartup()
{
    var startupPath = GetStartupShortcutPath();
    if (File.Exists(startupPath))
        File.Delete(startupPath);
} 

This code just copies the existing shortcut after installation in programs to startup group. RemoveShortcutFromStartup is called on uninstall.

Also you can start your application on start up by just adding (and removing on uninstall) another registry key:

C#
// The path to the key where Windows looks for startup applications
RegistryKey rkApp = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
//Path to launch shortcut
string startPath = Environment.GetFolderPath(Environment.SpecialFolder.Programs) + 
       @"\YourPublisher\YourSuite\YourProduct.appref-ms";
rkApp.SetValue("YourProduct", startPath); 

But in this article we just copy/remove icon from the application start up group. 

Registry update  

Image 6

Here parameters in the Registry are set which allows to set a custom icon in the Application Wizard (DisplayIcon key), uninstall string (UninstallString Key), and others. The unistall string is set to "uninstall.exe" and the original values are stored in the AppData folder in the UninstallString.bat file.  

C#
public void UpdateUninstallParameters()
{
    if (UninstallRegistryKey == null)
        return;
    UpdateUninstallString();
    UpdateDisplayIcon();
    SetNoModify();
    SetNoRepair();
    SetHelpLink();
    SetURLInfoAbout();
}

private RegistryKey GetUninstallRegistryKeyByProductName(string productName)
{
    var subKey = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall");
    if (subKey == null)
        return null;
    foreach (var name in subKey.GetSubKeyNames())
    {
        var application = subKey.OpenSubKey(name, RegistryKeyPermissionCheck.ReadWriteSubTree, 
            RegistryRights.QueryValues | RegistryRights.ReadKey | RegistryRights.SetValue);
        if (application == null)
            continue;
        foreach (var appKey in application.GetValueNames().Where(appKey => appKey.Equals(DisplayNameKey)))
        {
            if (application.GetValue(appKey).Equals(productName))
                return application;
            break;
        }
    }
    return null;
}

private void UpdateUninstallString()
{
    var uninstallString = (string)UninstallRegistryKey.GetValue(UninstallString);
    if (!String.IsNullOrEmpty(UninstallFile) && uninstallString.StartsWith("rundll32.exe"))
        File.WriteAllText(UninstallFile, uninstallString);
    var str = String.Format("\"{0}\" uninstall", 
        Path.Combine(Path.GetDirectoryName(Location), "uninstall.exe"));
    UninstallRegistryKey.SetValue(UninstallString, str);
}

private void UpdateDisplayIcon()
{
    var str = String.Format("{0},0", 
        Path.Combine(Path.GetDirectoryName(Location), "uninstall.exe"));
    UninstallRegistryKey.SetValue("DisplayIcon", str);
}

private void SetNoModify()
{
    UninstallRegistryKey.SetValue("NoModify", 1, RegistryValueKind.DWord);
}

private void SetNoRepair()
{
    UninstallRegistryKey.SetValue("NoRepair", 1, RegistryValueKind.DWord);
}

private void SetHelpLink()
{
    UninstallRegistryKey.SetValue("HelpLink", Globals.HelpLink, RegistryValueKind.String);
}

private void SetURLInfoAbout()
{
    UninstallRegistryKey.SetValue("URLInfoAbout", Globals.AboutLink, RegistryValueKind.String);
} 

 ClickOnceHelper Uninstall 

During ClickOnceHelper uninstall method execution, first of all, the process of the application is killed (you can add some messaging here to ask your app to close instead of killing the process, in real life you should not kill it).

Then the startup shortcut is removed and finally the standard ClickOnce uninstall is automated.

ClickOnce on uninstall by default shows the Restore/Remove dialog. In this scenario I don't want to provide these options or allow the user to interact with this dialog. So a new process is launched with data from UninstallString.bat. During uninstall, a custom dialog is shown so here we just automate the "remove" flow for the standard dialog.

Previously I saw automations to this dialog and they were a bit wrong:

  • Such code was used SendKeys.SendWait("+{TAB}"); // SHIFT-TAB. But it needs the window to be on the foreground and in focus, so the user or system can easily fail this automation.
  • The flow covered only one case when Restore is disabled. However if restore is possible it is in focus and you need to switch it to remove! I'm using right flow: Shift+Tab, Shift+Tab, Down, Tab, Enter.
C#
public void Uninstall()
{
    try
    {
        //kill process
        foreach (var process in Process.GetProcessesByName(ProductName))
        {
            process.Kill();
            break;
        }

        if (!File.Exists(UninstallFile)) 
            return;
        RemoveShortcutFromStartup();

        var uninstallString = File.ReadAllText(UninstallFile);
        var fileName = uninstallString.Substring(0, uninstallString.IndexOf(" "));
        var args = uninstallString.Substring(uninstallString.IndexOf(" ") + 1);
            
        var proc = new Process
                       {
                           StartInfo =
                               {
                                   Arguments = args,
                                   FileName = fileName,
                                   UseShellExecute = false
                               }
                       };

        proc.Start();
        RespondToClickOnceRemovalDialog();
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex);
    }
}

[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
static extern bool PostMessage(IntPtr hWnd, 
  [MarshalAs(UnmanagedType.U4)] uint Msg, IntPtr wParam, IntPtr lParam);
const int WM_KEYDOWN = 0x0100;
const int WM_KEYUP = 0x0101;

private void RespondToClickOnceRemovalDialog()
{
    var myWindowHandle = IntPtr.Zero;
    for (var i = 0; i < 250 && myWindowHandle == IntPtr.Zero; i++)
    {
        Thread.Sleep(150);
        foreach (var proc in Process.GetProcessesByName("dfsvc"))
            if (!String.IsNullOrEmpty(proc.MainWindowTitle) && 
                     proc.MainWindowTitle.StartsWith(ProductName))
            {
                myWindowHandle = proc.MainWindowHandle;
                break;
            }
    }
    if (myWindowHandle == IntPtr.Zero)
        return;

    SetForegroundWindow(myWindowHandle);
    Thread.Sleep(100);
    const uint wparam = 0 << 29 | 0;

    PostMessage(myWindowHandle, WM_KEYDOWN, (IntPtr)(Keys.Shift | Keys.Tab), (IntPtr)wparam);
    PostMessage(myWindowHandle, WM_KEYDOWN, (IntPtr)(Keys.Shift | Keys.Tab), (IntPtr)wparam);

    PostMessage(myWindowHandle, WM_KEYDOWN, (IntPtr)Keys.Down, (IntPtr)wparam);

    PostMessage(myWindowHandle, WM_KEYDOWN, (IntPtr)Keys.Tab, (IntPtr)wparam);

    PostMessage(myWindowHandle, WM_KEYDOWN, (IntPtr)Keys.Enter, (IntPtr)wparam);
}

History

  • 11.12.2012 - Some corrections. Launch on start up by updating only registry start up section comment is added.
  • 10.12.2012 - Initial version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)