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 application
Startup shortcut
Custom icon and links in Application Wizard
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:
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.
if (MessageBox.Show(Resources.Uninstall_Question, Resources.Uninstall +
Globals.ProductName, MessageBoxButtons.YesNo) == DialogResult.Yes)
{
var clickOnceHelper = new ClickOnceHelper(Globals.PublisherName, Globals.ProductName);
clickOnceHelper.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:
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:
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:
UninstallRegistryKey = GetUninstallRegistryKeyByProductName(ProductName);
Shortcut code:
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:
RegistryKey rkApp = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
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
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.
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.
public void Uninstall()
{
try
{
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.