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 { /// /// 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. /// public static class AutoUpdater { public static async Task ApplyUpdateAsync( UpdateCheckResult update, IProgress 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 приложения."); } // Переносим payload в путь без спецсимволов: имя корневой папки в архиве // бывает в чужой кодировке, и такой путь ломает аргумент SRC у robocopy // (особенно при запуске под elevated cmd с OEM-кодовой страницей). var asciiPayload = Path.Combine(workRoot, "payload"); try { if (Directory.Exists(asciiPayload)) { Directory.Delete(asciiPayload, true); } Directory.Move(payloadDir, asciiPayload); payloadDir = asciiPayload; } catch { // Если перенос не удался — работаем с исходным путём. } var needsElevation = !IsDirectoryWritable(installDir); var updaterBat = Path.Combine(workRoot, "apply-update.cmd"); WriteUpdaterScript(updaterBat); Report(status, needsElevation ? "Нужны права администратора для установки в Program Files..." : "Подготовка перезапуска..."); var batchArgs = Quote(payloadDir) + " " + Quote(installDir) + " " + Quote(Path.GetFileName(currentExe)) + " " + Process.GetCurrentProcess().Id; var psi = new ProcessStartInfo { UseShellExecute = true, WorkingDirectory = workRoot, WindowStyle = needsElevation ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden, CreateNoWindow = !needsElevation }; if (needsElevation) { // ВАЖНО: повышаем права через cmd.exe (PE-исполняемый), а НЕ через // .cmd-файл напрямую. ShellExecute с verb=runas на .cmd срабатывает не // на всех системах (у batch может не быть runas-ассоциации), из-за чего // elevated-процесс молча не запускается и обновление не применяется. psi.FileName = "cmd.exe"; psi.Arguments = "/c \"\"" + updaterBat + "\" " + batchArgs + "\""; psi.Verb = "runas"; } else { psi.FileName = updaterBat; psi.Arguments = batchArgs; } 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 status, string message) { AppDebugLog.Info("AutoUpdater", message); status?.Report(message); } } }