当前位置 : 主页 > 网络编程 > net编程 >

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

来源:互联网 收集:自由互联 发布时间:2023-09-03
前几天写了个「​​干掉微信只读​​」的程序,用来解决微信更新 3.9 以后收到文件会自动设置为只读的问题。微信这个设计可以有效地保证收到的原始文件安全性,避免被无意改动

前几天写了个「​​干掉微信只读​​」的程序,用来解决微信更新 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 的启动命令路径中由于存在空格,还使用了引号。


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

获取执行文件的路径

通过 ​​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"
)
);

注:​​LetWhenNot​​​ 是 ​​Viyi.Util​​​ 提供的扩展,类似的还使用了 ​​Let​​​、​​When​​​、​​Else​​ 等扩展,可以在源码(后附)中找到。

​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 程序解决 Windows 版微信 3.9 收到文件“只读”的问题​​
  • ​​下载编译好的(可能需要自行安装环境)​​
  • ​​学习源代码​​ 注意:直接克隆指定分支:
git clone -b WxFilesWritable https://gitee.com/jamesfancy/code-for-articles.git


【本文来源:香港将军澳机房 http://www.558idc.com/hk.html 欢迎留下您的宝贵建议】
网友评论