65.9K
CodeProject 正在变化。 阅读更多。
Home

ClickOnce 应用程序自动启动和干净卸载, 或自定义 ClickOnce 安装的方法

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (24投票s)

2012年12月10日

CPOL

5分钟阅读

viewsIcon

148559

downloadIcon

6365

实现 ClickOnce 的轻量级安装/卸载自定义的方法,包括自动启动和卸载时清理。

引言

ClickOnce 是一项不错的技术,但在某些情况下很难自定义。最近我有一个需求,要求 ClickOnce 应用程序在启动时运行。听起来很简单,对吧?但对于 ClickOnce 来说并非如此。你可以通过搜索发现,这曾是许多开发者的一个实际问题。 甚至 MSDN 在比较图表(MSI vs. ClickOnce)中也表示,ClickOnce 部署不支持启动时运行,并且在注册表访问方面也有局限性。

在本文中,我将介绍解决此问题的一种方法。它可能存在一些缺点,但很可靠,并且可以用于某些应用程序。 

功能

此解决方案允许您: 

  • 在启动组中添加应用程序的快捷方式,以便在启动时运行
  • 在卸载时从启动组删除快捷方式
  • 在卸载时清理任何您想清理的内容
  • 卸载时自动关闭应用程序(实际上是终止进程,但您可以添加一些消息提示并要求您的程序关闭)
  • 在应用程序向导中设置您喜欢的图标,而不是默认图标
  • 在应用程序向导中为您的应用程序设置一些帮助和关于链接

main window

主应用程序

启动快捷方式

Startup shortcut

应用程序向导中的自定义图标和链接

ClickOnce 的自定义卸载对话框

使用代码

注意:首次,请至少一次以 Release 模式构建解决方案,主项目中有一个链接(Release 文件夹中应存在 "uninstall.exe")。

该解决方案包含三个项目

  • WPF 应用程序,即我们要分发的应用程序
  • 公共库,包含 ClickOnceHelper
  • 卸载应用程序,在卸载时启动

WPF 应用程序和卸载应用程序都引用公共库。WPF 应用程序是要发布的应用程序,因此我们需要它包含实际的卸载可执行文件。为此,WPF 项目依赖于卸载项目,以便在主应用程序构建之前出现 *uninstall.exe*。为了能够将 *uninstall.exe* 包含在部署中,我从 *Release* 目录添加了一个指向 *uninstall.exe* 的链接。(我当时是在 Release 模式下构建的 *uninstall.exe*:右键单击主项目 -> 添加现有项 -> 在 *release* 目录中找到 *uninstall.exe* -> 从“添加”按钮的下拉菜单中选择“添加链接”)。因此,如果 Release 目录中没有 *uninstall.exe*,主项目将无法构建。请构建一次 Release 模式以进行调试/编译。

主应用程序

值得关注的代码位于 App 类中

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);
} 

每次启动时,都会检查应用程序和快捷方式的注册表参数。如果是第一次运行或有更新,注册表值将被更新,如果图标被删除,将重新创建。如果一切正常,代码几乎不做任何事情。

卸载应用程序  

这是一个简单的应用程序。它有一个互斥锁来检查单实例,并有代码执行卸载。

代码显示一个 Y/N 对话框。如果用户选择卸载应用程序,代码将使用 ClickOnceHelper 自动执行 ClickOnce 卸载,并执行您希望执行的其他自定义操作。在这种情况下,将从 AppData 中删除应用程序数据文件夹,该文件夹本应由主应用程序使用。

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);
} 

此外,命名为 setup*、install*、uninstall* 的 Windows 兼容性文件可能会导致应用程序崩溃,这是因为 Windows 不确定这些“setup”是否已正确完成。为了避免这种可能的应用程序崩溃,请嵌入自定义清单并取消注释支持的 OS GUID。

ClickOnceHelper 

功能的核心在这个类中。我将其分为三个区域

  • 快捷方式相关
  • 更新注册表
  • Uninstall

要使用此类,您需要提供发布者名称和产品名称。**它们应与主项目中的“发布”->“选项”值匹配。** 您可以在配置文件中提供它们,硬编码,或在运行时确定。在这些解决方案中,它们已在静态 Clobals 类中硬编码。

创建时,它会在应用程序数据中创建一个目录来存储原始卸载字符串:

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

它还在注册表中查找应用程序的卸载部分(注册表项):

UninstallRegistryKey = GetUninstallRegistryKeyByProductName(ProductName); 

快捷方式代码:  

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);
} 

此代码仅将安装后程序中的现有快捷方式复制到启动组。RemoveShortcutFromStartup 在卸载时被调用。

您还可以通过添加(并在卸载时删除)另一个注册表项来让您的应用程序在启动时运行

// 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); 

但在本文中,我们只是从应用程序启动组复制/删除图标。

注册表更新  

此处设置注册表中的参数,这些参数允许在应用程序向导中设置自定义图标(DisplayIcon 键)、卸载字符串(UninstallString 键)等。卸载字符串设置为 "uninstall.exe",原始值存储在 AppData 文件夹中的 UninstallString.bat 文件中。

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 卸载 

在 ClickOnceHelper 卸载方法执行期间,首先会终止应用程序进程(您可以在此处添加一些消息提示,要求您的应用程序关闭而不是终止进程,在实际应用中您不应该终止它)。

然后删除启动快捷方式,最后自动化标准的 ClickOnce 卸载。

ClickOnce 默认在卸载时显示“恢复/删除”对话框。在此场景中,我不想提供这些选项或允许用户与此对话框交互。因此,会启动一个新进程,其中包含来自 *UninstallString.bat* 的数据。在卸载期间,会显示一个自定义对话框,因此在这里我们只是自动化标准对话框的“删除”流程。

我之前看到过对该对话框的自动化,但它们有点错误

  • 曾使用此类代码 SendKeys.SendWait("+{TAB}"); // SHIFT-TAB。但这需要窗口处于前台并获得焦点,因此用户或系统很容易导致此自动化失败。
  • 该流程仅涵盖了一种情况,即禁用“恢复”。但是,如果“恢复”可用,它会获得焦点,您需要将其切换为“删除”!我使用的是正确的流程:Shift+Tab,Shift+Tab,Down,Tab,Enter。
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);
}

历史

  • 2012.12.11 - 一些修正。添加了通过仅更新注册表启动部分来实现启动时运行的注释。
  • 2012.12.10 - 初始版本。
© . All rights reserved.