前幾天寫了個「幹掉微信只讀」的程式,用來解決微信更新 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