Auto-updater now requests UAC elevation, force-unlocks the exe, and verifies the copy before restart. Co-authored-by: Cursor <cursoragent@cursor.com>
260 lines
12 KiB
C#
260 lines
12 KiB
C#
using System;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace Электронная_Фармация.Classes
|
||
{
|
||
/// <summary>
|
||
/// Downloads a Gitea release ZIP and applies it via an external batch updater
|
||
/// so the running exe can be replaced. Preserves local databases and Logs.
|
||
/// </summary>
|
||
public static class AutoUpdater
|
||
{
|
||
public static async Task ApplyUpdateAsync(
|
||
UpdateCheckResult update,
|
||
IProgress<string> status = null)
|
||
{
|
||
if (update == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(update));
|
||
}
|
||
|
||
var downloadUrl = !string.IsNullOrWhiteSpace(update.DownloadUrl)
|
||
? update.DownloadUrl
|
||
: update.ReleaseUrl;
|
||
if (string.IsNullOrWhiteSpace(downloadUrl) ||
|
||
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(downloadUrl) &&
|
||
!downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new InvalidOperationException(
|
||
"В релизе нет ZIP-сборки. Прикрепите ElectronicPharmacy-vX.Y.Z.zip к Release.");
|
||
}
|
||
|
||
throw new InvalidOperationException("Нет ссылки на ZIP обновления.");
|
||
}
|
||
|
||
var installDir = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(
|
||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||
var currentExe = Process.GetCurrentProcess().MainModule?.FileName;
|
||
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
|
||
{
|
||
currentExe = Directory.GetFiles(installDir, "*.exe")
|
||
.FirstOrDefault(p => !p.EndsWith(".vshost.exe", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(currentExe))
|
||
{
|
||
throw new InvalidOperationException("Не найден текущий exe.");
|
||
}
|
||
|
||
var workRoot = Path.Combine(Path.GetTempPath(), "elfisa-update-" + Guid.NewGuid().ToString("N"));
|
||
var zipPath = Path.Combine(workRoot, "update.zip");
|
||
var extractDir = Path.Combine(workRoot, "extract");
|
||
Directory.CreateDirectory(extractDir);
|
||
|
||
try
|
||
{
|
||
Report(status, "Скачивание обновления...");
|
||
await DownloadAsync(downloadUrl, zipPath).ConfigureAwait(false);
|
||
|
||
Report(status, "Распаковка...");
|
||
ZipFile.ExtractToDirectory(zipPath, extractDir);
|
||
|
||
var payloadDir = FindPayloadDirectory(extractDir);
|
||
if (payloadDir == null)
|
||
{
|
||
throw new InvalidOperationException("В архиве не найден exe приложения.");
|
||
}
|
||
|
||
var needsElevation = !IsDirectoryWritable(installDir);
|
||
var updaterBat = Path.Combine(workRoot, "apply-update.cmd");
|
||
WriteUpdaterScript(updaterBat);
|
||
|
||
Report(status, needsElevation
|
||
? "Нужны права администратора для установки в Program Files..."
|
||
: "Подготовка перезапуска...");
|
||
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = updaterBat,
|
||
Arguments = Quote(payloadDir) + " " + Quote(installDir) + " " +
|
||
Quote(Path.GetFileName(currentExe)) + " " +
|
||
Process.GetCurrentProcess().Id,
|
||
UseShellExecute = true,
|
||
WorkingDirectory = workRoot,
|
||
WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden,
|
||
CreateNoWindow = !needsElevation
|
||
};
|
||
if (needsElevation)
|
||
{
|
||
// UAC prompt — без этого robocopy в Program Files молча не заменяет exe.
|
||
psi.Verb = "runas";
|
||
}
|
||
|
||
try
|
||
{
|
||
Process.Start(psi);
|
||
}
|
||
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||
{
|
||
// ERROR_CANCELLED — пользователь отклонил UAC
|
||
throw new InvalidOperationException(
|
||
"Обновление отменено: нужны права администратора " +
|
||
"(программа установлена в Program Files).",
|
||
ex);
|
||
}
|
||
|
||
AppDebugLog.Info("AutoUpdater",
|
||
$"Updater started. elevate={needsElevation}, payload={payloadDir}, install={installDir}, ver={update.LatestVersion}");
|
||
}
|
||
catch
|
||
{
|
||
try
|
||
{
|
||
if (Directory.Exists(workRoot))
|
||
{
|
||
Directory.Delete(workRoot, true);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore cleanup errors
|
||
}
|
||
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private static bool IsDirectoryWritable(string directory)
|
||
{
|
||
try
|
||
{
|
||
var probe = Path.Combine(directory, ".elfisa-write-" + Guid.NewGuid().ToString("N"));
|
||
File.WriteAllText(probe, "ok");
|
||
File.Delete(probe);
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static async Task DownloadAsync(string url, string zipPath)
|
||
{
|
||
using (var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) })
|
||
{
|
||
var token = AppConfig.UpdateApiToken;
|
||
if (!string.IsNullOrWhiteSpace(token))
|
||
{
|
||
client.DefaultRequestHeaders.Authorization =
|
||
new AuthenticationHeaderValue("token", token.Trim());
|
||
}
|
||
|
||
using (var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)
|
||
.ConfigureAwait(false))
|
||
{
|
||
response.EnsureSuccessStatusCode();
|
||
using (var fs = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||
{
|
||
await response.Content.CopyToAsync(fs).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static string FindPayloadDirectory(string extractRoot)
|
||
{
|
||
// ZIP may contain a single root folder (Compress-Archive of a directory).
|
||
var exe = Directory.GetFiles(extractRoot, "*.exe", SearchOption.AllDirectories)
|
||
.FirstOrDefault(p =>
|
||
{
|
||
var name = Path.GetFileName(p) ?? string.Empty;
|
||
return !name.EndsWith(".vshost.exe", StringComparison.OrdinalIgnoreCase) &&
|
||
!name.Equals("UpdateCheckSmoke.exe", StringComparison.OrdinalIgnoreCase) &&
|
||
!name.StartsWith("unins", StringComparison.OrdinalIgnoreCase);
|
||
});
|
||
|
||
return exe == null ? null : Path.GetDirectoryName(exe);
|
||
}
|
||
|
||
private static void WriteUpdaterScript(string path)
|
||
{
|
||
// Waits for app exit, force-kills leftovers, renames locked exe,
|
||
// copies with robocopy, verifies, restarts. Log: %TEMP%\elfisa-updater.log
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("@echo off");
|
||
sb.AppendLine("setlocal EnableDelayedExpansion");
|
||
sb.AppendLine("set \"SRC=%~1\"");
|
||
sb.AppendLine("set \"DST=%~2\"");
|
||
sb.AppendLine("set \"EXENAME=%~3\"");
|
||
sb.AppendLine("set \"PID=%~4\"");
|
||
sb.AppendLine("set \"LOG=%TEMP%\\elfisa-updater.log\"");
|
||
sb.AppendLine("echo ===== %DATE% %TIME% ===== > \"%LOG%\"");
|
||
sb.AppendLine("echo SRC=%SRC%>> \"%LOG%\"");
|
||
sb.AppendLine("echo DST=%DST%>> \"%LOG%\"");
|
||
sb.AppendLine("echo EXE=%EXENAME% PID=%PID%>> \"%LOG%\"");
|
||
sb.AppendLine("set \"N=0\"");
|
||
sb.AppendLine("echo Waiting for PID %PID% ... >> \"%LOG%\"");
|
||
sb.AppendLine(":waitloop");
|
||
sb.AppendLine("tasklist /FI \"PID eq %PID%\" 2>nul | findstr /C:\" %PID% \" >nul");
|
||
sb.AppendLine("if errorlevel 1 goto donewait");
|
||
sb.AppendLine("set /a N+=1");
|
||
sb.AppendLine("if !N! GEQ 40 (");
|
||
sb.AppendLine(" echo force kill PID %PID% >> \"%LOG%\"");
|
||
sb.AppendLine(" taskkill /PID %PID% /F >nul 2>&1");
|
||
sb.AppendLine(" goto donewait");
|
||
sb.AppendLine(")");
|
||
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
|
||
sb.AppendLine("goto waitloop");
|
||
sb.AppendLine(":donewait");
|
||
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
|
||
// Kill any leftover instances by image name (locks Program Files exe).
|
||
sb.AppendLine("echo taskkill by name >> \"%LOG%\"");
|
||
sb.AppendLine("taskkill /F /IM \"%EXENAME%\" >nul 2>&1");
|
||
sb.AppendLine("ping 127.0.0.1 -n 2 >nul");
|
||
// Rename locked/old exe so copy can proceed even if handle lingered.
|
||
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
|
||
sb.AppendLine("if exist \"%DST%\\%EXENAME%\" (");
|
||
sb.AppendLine(" move /Y \"%DST%\\%EXENAME%\" \"%DST%\\%EXENAME%.old\" >> \"%LOG%\" 2>&1");
|
||
sb.AppendLine(")");
|
||
sb.AppendLine("echo Copying update... >> \"%LOG%\"");
|
||
// /IS /IT — force overwrite even if timestamps look the same
|
||
sb.AppendLine("robocopy \"%SRC%\" \"%DST%\" /E /IS /IT /R:5 /W:1 /NFL /NDL /NJH /NJS /XF efClient.db *.db /XD Logs");
|
||
sb.AppendLine("set \"RC=!ERRORLEVEL!\"");
|
||
sb.AppendLine("echo robocopy=!RC! >> \"%LOG%\"");
|
||
sb.AppendLine("if not exist \"%DST%\\%EXENAME%\" (");
|
||
sb.AppendLine(" echo COPY FAILED — restoring old exe >> \"%LOG%\"");
|
||
sb.AppendLine(" if exist \"%DST%\\%EXENAME%.old\" move /Y \"%DST%\\%EXENAME%.old\" \"%DST%\\%EXENAME%\" >nul 2>&1");
|
||
sb.AppendLine(" echo Не удалось заменить файлы программы. См. %TEMP%\\elfisa-updater.log");
|
||
sb.AppendLine(" pause");
|
||
sb.AppendLine(" exit /b 1");
|
||
sb.AppendLine(")");
|
||
sb.AppendLine("if exist \"%DST%\\%EXENAME%.old\" del /F /Q \"%DST%\\%EXENAME%.old\" >nul 2>&1");
|
||
sb.AppendLine("echo Starting \"%DST%\\%EXENAME%\" >> \"%LOG%\"");
|
||
sb.AppendLine("start \"\" /D \"%DST%\" \"%DST%\\%EXENAME%\"");
|
||
sb.AppendLine("exit /b 0");
|
||
File.WriteAllText(path, sb.ToString(), Encoding.Default);
|
||
}
|
||
|
||
private static string Quote(string value)
|
||
{
|
||
return "\"" + (value ?? string.Empty).Replace("\"", string.Empty) + "\"";
|
||
}
|
||
|
||
private static void Report(IProgress<string> status, string message)
|
||
{
|
||
AppDebugLog.Info("AutoUpdater", message);
|
||
status?.Report(message);
|
||
}
|
||
}
|
||
}
|