elfisa-pharmacy/src/ElectronicPharmacy/Classes/AutoUpdater.cs
Exest d2a1a86794 Release v1.0.22: fix auto-update loop on Program Files installs.
Auto-updater now requests UAC elevation, force-unlocks the exe, and verifies the copy before restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:09:57 +03:00

260 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
}