前几天写了个「干掉微信只读」的程序,用来解决微信更新 3.9 以后收到文件会自动设置为只读的问题。微信这个设计可以有效地保证收到的原始文件安全性,避免被无意改动。但确实有违某些用户的习惯性操作。「干掉微信只读」从技术角度研究了用 .NET 程序解决问题的手段,同时也提供了 Demo 程序。有用户返回 Demo 很好用,就是每次开发需要手工启动不太方便。
作为一个监控类程序,设置开机自启确实是刚需,所以接下来就对这个程序进行一些改进。
一、设置自启动的方法
对于 Windows 来说,设置自启动主要有三个途径:
- 修改注册表添加自启动项;
- 在开发菜单添加自启动项;
- 使用计划任务启动。
对于这三种方法,最简单的是第 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
了。
用户体验设计
拿到了可执行文件路径之后,当然可以直接写注册表了。但问题在于,主程序的执行逻辑并不会发生变化,它仍然只是弹了一个框出来,等待用户确认/修改微信接收文件的路径,再开启「监听」。这一步保留用户干预会大大降低自启动的用户体验。所以在优化用户体验方面,需要考虑两种情况:
- 用户自己启动程序的时候,先确认路径,再监听。这就是原来的逻辑,不用改变。
- 自启动的时候,能自动监听。但监听的路径肯定不能是
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()
本身,而是在某些情况下,不需要再去猜目录了。
- 通过参数传入了路径的情况下,不需要猜
- 如果注册表里有启动项设置,也不需要猜。
这里有个问题:如果有注册自启动,不应该是通过参数传入了路径吗?怎么还需要去检查注册表的启动设置?
话虽如此,但谁能预测用户行为呢。不管是否自启动,用户都可以手工双击启动,不带参数啊!
这样一来,给 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