给 .NET 程序加个「设置开机启动」

前几天写了个「干掉微信只读」的程序,用来解决微信更新 3.9 以后收到文件会自动设置为只读的问题。微信这个设计可以有效地保证收到的原始文件安全性,避免被无意改动。但确实有违某些用户的习惯性操作。「干掉微信只读」从技术角度研究了用 .NET 程序解决问题的手段,同时也提供了 Demo 程序。有用户返回 Demo 很好用,就是每次开发需要手工启动不太方便。

作为一个监控类程序,设置开机自启确实是刚需,所以接下来就对这个程序进行一些改进。

一、设置自启动的方法

对于 Windows 来说,设置自启动主要有三个途径:

  1. 修改注册表添加自启动项;
  2. 在开发菜单添加自启动项;
  3. 使用计划任务启动。

对于这三种方法,最简单的是第 1 种,使用 Microsoft.Win32.Registry 相关 API 写注册表就好。

最干净的是第 2 种,在开始菜单 程序\启动 添加一个快捷方式,不需要了要删除也好找。在程序里创建快捷方式需要使用 Windows Script Host Object Model,需要添加相应的 COM 组件引用,使用 WshSehll 来实现。

最复杂的是第 3 种,因为做计划任务需要的配置内容比较多。这种方式也需要添加 COM 组件引用(搜索 TaskScheduler)。

相对来说,第 1 种方式最为轻量、简单,这里采用第 1 种方式:修改注册表。

二、技术分析及处理过程

添加复选框来设置/取消自启动

界面上不用想太复杂,加一个复选框控件,勾上就写注册项,去掉勾选就删除注册项。逻辑很简单:

AutoStartup.CheckedChanged += (_, _) => {
    if (AutoStartup.Checked) {
        // TODO 添加注册表项
    }
    else {
        // TODO 删除注册表项
    }
};

不过需要注意的是,程序启动之后会去检查注册表看是否设置了自启动,如果设置了会将选框勾上。此时如果已经注册了 CheckedChanged 事件处理函数,那么会再次进入“添加注册表项”的逻辑。为了避免这种事情发生,添加事件处理函数必须在初始化 AutuStartup.Checked 之后。

了解如何写注册表值

注册表项需要添加在 HKEY_CURRENT_USER 下的 SOFTWARE\Microsoft\Windows\CurrentVersion\Run 键中,字符串值 (REG_SZ)。值的名称任意,一般是应用程序名;值的数据就是一个含参数的命令行。

如果不能确定「数据」该如何设置,可以看看现有的自启动项设置。比如下图中金山文档的启动命令就是一个带参数的命令行。而 EverythingToolbar 的启动命令路径中由于存在空格,还使用了引号。

给 .NET 程序加个「设置开机启动」_第1张图片

了解注册表自启动项的设置方法之后,我们知道需要找到执行文件的路径来组成自启动命令。

获取执行文件的路径

通过 AppContext.BaseDirectory 很容易得到执行文件所在目录,但还需要补文件名才是执行文件路径。与其去找文件名,不如就用 Assembly.GetExecutingAssembly().Location 还直接一些。

在实际开发中,该方法获取执行文件路径确实工作良好,直到 —— 发布。采用“生成单一文件 (PublishSingleFile) ”发布出来之后得到的路径是空值,而且这个现象好像是最近才出现的,它很可能跟更新 SDK 有关(刚更新了 VS2022 和 .NET 6 SDK)。关于这个问题在 Github 上可以找到很多讨论,最终的解决办法是使用 Process.GetCurrentProcess().MainModule.FileName

注意到 MainModule 的类型是 ProcessModule?,也就是说可能为 null。为了稳妥起见,干脆两个方法都用上。

private static string? executable;
public static string Executable => executable ??= (
    Process.GetCurrentProcess().MainModule?.FileName
        ?? Assembly.GetExecutingAssembly().Location.LetWhenNot(
            path => path.EndsWith(".exe", true, null),
            path => $"{path[..^Path.GetExtension(path).Length]}.exe"
        )
);
注:LetWhenNotViyi.Util 提供的扩展,类似的还使用了 LetWhenElse 等扩展,可以在源码(后附)中找到。

Assembly.GetExecutingAssembly().Location 有可能得到的是一个 DLL,所以这里直接暴力处理成 .exe 了。

用户体验设计

拿到了可执行文件路径之后,当然可以直接写注册表了。但问题在于,主程序的执行逻辑并不会发生变化,它仍然只是弹了一个框出来,等待用户确认/修改微信接收文件的路径,再开启「监听」。这一步保留用户干预会大大降低自启动的用户体验。所以在优化用户体验方面,需要考虑两种情况:

  1. 用户自己启动程序的时候,先确认路径,再监听。这就是原来的逻辑,不用改变。
  2. 自启动的时候,能自动监听。但监听的路径肯定不能是 GuessReceivePath() 得到的,因为它不能保证正确。

这样一来,在用户设置自启动的时候就需要设置监听路径,这个路径仍然可以来自 ReceivePath.Text,但必须保存下来。这个值保存成配置文件或者保存到注册表都是可选的方案。不过我选择了另一个方案:不保存,而是作为自启动命令的参数传入。

当程序启动检查到有传入参数的时候,就把这个参数作为监听路径,立即隐藏窗口,开始监听。这部分逻辑:

if (args.length > 0) {
    ReceivedPath.Text = args[0];
    StartWatch().Then(Hide);
}

但是很遗憾,这里又有坑 —— Hide 在窗体的构造和 Load 阶段都不起作用。

这里有两个办法,一个是在 Shown 事件中去隐藏,另一个是在 Load 事件中通过 BeginInvoke(Hide) 来调用隐藏。BeginInvoke() 是一个协调线程间操作的方法,它在一定程度上会等待主线程(UI 线程)完成某些操作。虽然文档中没有明确的说明它的运作机制,但是实测有效。

Load 中去隐藏窗体相对简单,因为 Load 事件只会在窗体的生命周期中出现一次。但 Shown 就不同了,只要显示出来就会执行。如果在 Shown 中隐藏窗口,在用户点击任务栏图标希望显示窗口的时候,会陷入自动隐藏的死循环,所以这里在第一次隐藏之后就需要把事件处理函数注销掉:

Load 事件中处理的方式相对简单,就不写示例了。
if (args.length > 0) {
    //...
    // 定义局部函数作为处理函数,私有实例函数也行
    void handle(object? sender, EventArgs e) {
        StartWatch().Then(Hide);
        Shown -= handle;    // ← 注销处理函数
    }
    Shown += handle;        // ← 注册处理函数
}

总算到了写注册表的环节

一切具备,只差写注册表了,其实很简单,就一句话:

RegistryKey Key = Registry.CurrentUser.CreateSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run");
Key.SetValue(AppName, $""" "{AppHelper.Executable}" "{wxFilePath}" """.Trim());

其中 AppHelper.Executable 拿到了执行文件路径,wxFilePath 则是需要监听的目录。

为了不去转义引号,这里使用了 C# 11 的 Raw string literals(原始字符串文本)。这个语法使用至少三个引号作为限界符,分单行和多行两种情况。上面使用了单行语法,直接踩进了坑里 —— 字符串内容是以双引号开始或结尾的,词法分析会以为那是限界符的一部分,所以只能用多余的空格来分隔,最后再通过 Trim() 把空格去掉。

Key.SetValue(AppName, $""" "{AppHelper.Executable}" "{wxFilePath}" """.Trim());
//                        ^                                       ^      需要加空格来分隔
//                     ^^^                                         ^^^   一对限界符
//                         ^                                     ^       内容中的引号

当然如果用多行写法就不会出现这种问题:

Key.SetValue(AppName, $"""
    "{AppHelper.Executable}" "{wxFilePath}"
    """);

在删除这个注册值的时候也需要注意,如果这个值不存在会抛 ArgumentException。比较暴力的解决办法是抓住异常,忽略掉

try { Key.DeleteValue(AppName); }
catch (ArgumentException) {
    // ignore
}

也可以事先判断是否存在。RegisterKey 并没有提供判断值是否存在的 API,但可以通过 GetValue() 来取值,如果取值为 null 则表示不存在(如果是未设置有效字符串数据,取值会得到 "")。

还可以优化一下 GuessReceivePath

当然不是优化 GuessReceivePath() 本身,而是在某些情况下,不需要再去猜目录了。

  1. 通过参数传入了路径的情况下,不需要猜
  2. 如果注册表里有启动项设置,也不需要猜。

    这里有个问题:如果有注册自启动,不应该是通过参数传入了路径吗?怎么还需要去检查注册表的启动设置?

    话虽如此,但谁能预测用户行为呢。不管是否自启动,用户都可以手工双击启动,不带参数啊!

这样一来,给 ReceivePath.Text 赋初始值的逻辑就会有一个优先级的处理:

ReceivePath.Text = argPath ?? regPath ?? GuessReceivePath();

argPath 来自程序的启动参数,regPath 则是从注册表值中分析出来的。这个分析过程要细致的话,不仅需要把参数分析出来(万一手工设置不带参数呢),还需要兼容处理含引号和不含引号两种情况。当然对于这样一个小程序,就不做这么细致了,粗暴地根据程序设置的方式来解析(假设取到的值就是这个程序设置的)。

相关资源

你可能感兴趣的:(给 .NET 程序加个「设置开机启动」)