給 .NET 程式加個「設定開機啟動」

邊城發表於2023-03-06

前幾天寫了個「幹掉微信只讀」的程式,用來解決微信更新 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 的啟動命令路徑中由於存在空格,還使用了引號。

image.png

瞭解登錄檔自啟動項的設定方法之後,我們知道需要找到執行檔案的路徑來組成自啟動命令。

獲取執行檔案的路徑

透過 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 則是從登錄檔值中分析出來的。這個分析過程要細緻的話,不僅需要把引數分析出來(萬一手工設定不帶引數呢),還需要相容處理含引號和不含引號兩種情況。當然對於這樣一個小程式,就不做這麼細緻了,粗暴地根據程式設定的方式來解析(假設取到的值就是這個程式設定的)。

相關資源

相關文章