Windows 程式自動更新方案: Squirrel.Windows
1. Squirrel
Squirrel 是一組工具和適用於.Net的庫,用於管理 Desktop Windows 應用程式的安裝和更新. Squirrel 對 Desktop Windows 應用程式的實現語言沒有任何要求.
2. 下載相關工具
- Squirrel.Windows
- Nuget Commandline
- rcedit
- Resource Hacker: 可選, 用於檢視可執行檔案的資源.
3. 環境準備
- 解壓下載的 Squirrel.Windows.zip 檔案.
- 因為 Squirrel.Windows 中自帶的 rcedit.exe 是比較老的版本, 不支援中文字符集,所以需要下載最新的 rcedit.exe 檔案覆蓋到 Squirrel.Windows 目錄中.
- 將 nuget.exe 、rcedit.exe 及 Squirrel.exe 所在資料夾加入到環境變數 Path 中,方便命令列使用
- Squirrel.exe 通過 呼叫
NativeMethods.VerQueryValue
方法在可執行檔案的版本資源(BLOCK "040904B0") 中查詢 SquirrelAwareVersion 資訊. 若存在該值且大於等於1, 則認為該程式為 SquirrelAwareApp. 但是 dotnet5 可執行檔案的 VersionInfo 儲存在 BLOCK "000004b0" 中, 所以 Squirrel 的 SquirrelAware 功能暫時不支援 dotnet5 應用. - 有人已經提交了修復該問題的 PR , 截止到現在該 PR 尚未合併. 為解決 dotnet5 程式裡面 SquirrelAwareVersion 的問題, 需要自己通過修改原始碼重新發布 Squirrel.exe 的方式來增加 dotnet5 的支援. 另外一種更簡單的方法是通過 DnSpy 反編譯修改
Squirrel.SquirrelAwareExecutableDetector.GetVersionBlockSquirrelAwareValue()
方法, 修改後如下:
int fileVersionInfoSize = NativeMethods.GetFileVersionInfoSize(executable, IntPtr.Zero);
if (fileVersionInfoSize <= 0 || fileVersionInfoSize > 4096)
{
return null;
}
byte[] array = new byte[fileVersionInfoSize];
if (!NativeMethods.GetFileVersionInfo(executable, 0, fileVersionInfoSize, array))
{
return null;
}
IntPtr intPtr;
int num;
if (!new string[]
{
"040904B0",
"000004B0"
}.Any((string languageCode) => NativeMethods.VerQueryValue(array, "\\StringFileInfo\\" + languageCode + "\\SquirrelAwareVersion", out intPtr, out num)))
{
return null;
}
return new int?(1);
4. 使用 Squirrel 釋出更新包及安裝包
-
準備好需要整合自動更新的程式.
建立一個 bin 資料夾,將可執行 exe 檔案及所有依賴檔案拷貝進去.
-
可選: 使用 rcedit 設定可執行檔案的 version string.
設定完成後可以通過 Resource Hacker 檢視是否正確設定.(如果設定該項, 需要自己處理 Squirrel 事件來建立桌面快捷方式. 如果不設定,會自動查詢所有的 .exe 檔案,並建立快捷方式)
rcedit ./bin/MyApp.exe --set-version-string "SquirrelAwareVersion" "1"
-
建立並修改 .nuspec 檔案.
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<!--包名、應用安裝位置名稱-->
<id>MyApp</id>
<version>2.0.0</version>
<!--快捷方式名稱、windows應用管理器中的應用名稱-->
<title>包裝確認臺</title>
<authors>SanHua Inc.</authors>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>XXX包裝確認臺.</description>
<!--指定 codepage 支援中文字元-->
<language>zh-CN</language>
</metadata>
<files>
<!--Squirrel.Windows預設使用 lib\net45\ 目錄作為 app 所在資料夾-->
<file src="bin\**" target="lib\net45\" />
</files>
</package>
-
使用 nuget 命令進行打包
nuget pack MyApp.nuspec
-
使用 Squirrel 命令釋出更新包.
可以通過 -icon -setupIcon 選項來指定圖示
Squirrel --releasify MyApp.2.0.0.nupkg -icon favicon.ico -setupIcon favicon.ico
-
Releases 目錄
Squirrel --releasify
命令成功執行後會生成一個 Releases 資料夾. 該資料夾下的內容需要保留, 用於下次釋出時生成 *-delta.nupkg 增量更新包.
- RELEASES # 該檔案記錄了各個版本包的名稱、hash、大小
- Setup.exe # 提供給使用者的 exe 安裝包
- Setup.msi # 提供給使用者的 msi 安裝包
- MyApp-2.0.0-full.nupkg # 基礎包
- MyApp-2.0.1-full.nupkg # 全量更新包
- MyApp-2.0.1-delta.nupkg # 增量更新包
-
app 安裝目錄
使用者安裝 app 後, 安裝位置為 %LocalAppData%\MyApp, 目錄結構如下:
- RELEASES # 該檔案記錄了各個版本包的名稱、hash、大小
- SquirrelSetup.log # Squirrel 執行日誌
- Update.exe # Squirrel.exe 的拷貝, 用於執行 Squirrel 命令來實現應用的更新、解除安裝、建立快捷方式等等
- MyApp.exe # StubExecutable.exe 的拷貝, 該 c++ 程式查詢 ./app-x.y.z 目錄下與自己同名的 exe
- # 通過啟動新程式來呼叫最新版的 app.exe , 比如 app-2.0.1/MyApp.exe
- # 建立的桌面快捷方式指向的就是它
- app-2.0.0/** # 上個版本 app 安裝位置
- app-2.0.1/** # 最新版本 app 安裝位置
5. 使用 Squirrel 進行自動更新
-
將 RELEASES 、*-full.nupkg、 *-delta.nupkg 檔案託管到靜態檔案伺服器上.
-
通過呼叫
Update.exe --UpdateUrl remoteUrl
來實現自動更新. -
Squirrel 事件
Squirrel 程式通過啟動子程式呼叫你的應用來傳遞事件訊息. 比如說第一次安裝完成時, 會通過以下命令來傳遞事件訊息:
MyApp.exe --squirrel-install x.y.z.m
-
使用 C# 程式碼實現後臺定時檢查更新
以下程式碼使用 Process 啟動更新程式, 並在程式中使用定時器來定期檢查更新.
需要注意的是執行安裝程式時, 會先將程式解壓到 %LocalAppData%\SquirrelTemp 目錄中, 此時 squirrel.exe 的工作目錄也在此處. 在安裝結束啟動新程式呼叫 app 傳遞事件訊息時, 子程式的工作目錄預設與父程式相同. 所以在處理--squirrel-install
事件時, 為了呼叫 squirrel.exe 來建立快捷方式, 必須指定絕對路徑, 否則會找不到可執行檔案.
public static async Task<int> InvokeProcessAsync(string fileName, string arguments, string workingDirectory = null)
{
var activity = new Activity(SquirrelDiagnosticListenerExtensions.ExecuteCommand);
s_diagnosticListener.WriteStartActivity(activity, new { fileName, arguments, workingDirectory, AppDomain.CurrentDomain.BaseDirectory });
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo()
{
// 可執行檔案查詢順序:
// 1. 絕對路徑
// 2. Environment.ProcessPath 或 Process.GetCurrentProcess().MainModule.FileName 下查詢
// 3. Directory.GetCurrentDirectory() 下查詢
// 4. PATH 環境變數中查詢
FileName = fileName ?? string.Empty,
Arguments = arguments ?? string.Empty,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory ?? string.Empty,// 為空時取 Directory.GetCurrentDirectory()
RedirectStandardInput = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
process.Start();
var cts = new CancellationTokenSource();
cts.CancelAfter(1000 * 300);
await process.WaitForExitAsync(cts.Token); // 設定程式超時時間
bool Timeout = false;
if (!process.HasExited)
{
Timeout = true;
process.Kill(); // 如果超時, 則強制退出程式
}
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
s_diagnosticListener.WriteStopActivity(activity, new { process.ExitCode, Timeout, stdout, stderr });
return process.ExitCode;
}
}
public void EanbleAutoUpdate()
{
if (_timer == null)
{
var timer = new System.Threading.Timer(async (object state) => await UpdateAsync(), null, Timeout.Infinite, Timeout.Infinite);
Interlocked.CompareExchange(ref _timer, timer, null);
}
_timer.Change(TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(3));
}
-
在程式入口點處理 squirrel 事件
以下程式碼在初次安裝成功後建立桌面快捷方式, 在解除安裝時刪除快捷方式.
var AppDirectory = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".."));
var SquirrelFilePath = Path.Combine(AppDirectory, "Update.exe");
var AppName = new DirectoryInfo(AppDirectory).Name;
await UpdateManager.HandleSquirrelEventsAsync(
async version => await UpdateManager.InvokeProcessAsync(SquirrelFilePath, $"--createShortcut {AppName}.exe", AppDirectory),//建立快捷方式
async version => await UpdateManager.InvokeProcessAsync(SquirrelFilePath, $"--removeShortcut {AppName}.exe", AppDirectory),//刪除快捷方式
(version) => { MessageBox.Show($"onAppUpdate 版本:{version} 已下載完成, 請關閉應用."); return Task.CompletedTask; },
(version) => { MessageBox.Show("onAppObsoleted"); return Task.CompletedTask; },
() => { MessageBox.Show("歡迎使用本 APP !"); return Task.CompletedTask; });
-
HandleSquirrelEventsAsync
方法
Squirrel.SquirrelAwareApp.HandleEvents 方法用於幫助處理 Squirrel 事件. 以下程式碼在原始碼的基礎上調整為非同步委託, 並在委託呼叫失敗時, 將錯誤資訊寫入 Windows Application event log, 方便除錯.
/// <summary>
/// 處理 Squirrel 事件
/// </summary>
/// <param name="onInitialInstall">在應用程式初始化安裝結束時呼叫</param>
/// <param name="onAppUpdate">在應用程式更新結束時呼叫</param>
/// <param name="onAppObsoleted">在應用程式不是最新版本時呼叫</param>
/// <param name="onAppUninstall">在應用程式解除安裝結束時呼叫</param>
/// <param name="onFirstRun">在應用程式第一次啟動時呼叫</param>
public static async Task HandleSquirrelEventsAsync(
Func<Version, Task> onInitialInstall = null,
Func<Version, Task> onAppUninstall = null,
Func<Version, Task> onAppUpdate = null,
Func<Version, Task> onAppObsoleted = null,
Func<Task> onFirstRun = null,
string[] arguments = null)
{
Func<Version, Task> defaultBlock = v => Task.CompletedTask;
var args = arguments ?? Environment.GetCommandLineArgs().Skip(1).ToArray();
if (args.Length == 0) return;
var lookup = new[] {
new { Key = "--squirrel-install", Value = onInitialInstall ?? defaultBlock },
new { Key = "--squirrel-updated", Value = onAppUpdate ?? defaultBlock },
new { Key = "--squirrel-obsolete", Value = onAppObsoleted ?? defaultBlock },
new { Key = "--squirrel-uninstall", Value = onAppUninstall ?? defaultBlock },
}.ToDictionary(k => k.Key, v => v.Value);
if (args[0] == "--squirrel-firstrun")
{
await onFirstRun?.Invoke();
return;
}
if (args.Length != 2 || !lookup.ContainsKey(args[0]) )
{
return;
}
try
{
var version = new Version(args[1]);
await lookup[args[0]](version);
Environment.Exit(0);
}
catch (Exception ex)
{
Environment.FailFast($"Fatal Exception Occurs When Handle Squirrel Events With Arguments '{args}'", ex);
}
}
6. 其他更新方案
除了 Squirrel 之外, 桌面平臺的自動更新方案還有 Google Omaha、AutoUpdater.NET、WinSparkle等等. omaha-consulting.com
上面有篇文章詳細介紹了這幾種自動更新方案的實現細節, 詳情見文末連結.