From 3039c324be8659af3f86cccbeef3f6cf294997a4 Mon Sep 17 00:00:00 2001 From: Exest Date: Fri, 31 Jul 2026 13:44:44 +0300 Subject: [PATCH] Add in-app auto-updater that preserves local database. Co-authored-by: Cursor --- src/ElectronicPharmacy/App.config | 3 + src/ElectronicPharmacy/Classes/AppConfig.cs | 21 ++ src/ElectronicPharmacy/Classes/AutoUpdater.cs | 192 ++++++++++++++++++ .../ElectronicPharmacy.csproj | 3 + .../HelpForms/HF_UpdateAvailable.cs | 65 ++++-- src/ElectronicPharmacy/Program.cs | 1 + .../Properties/AssemblyInfo.cs | 4 +- .../Properties/Settings.Designer.cs | 12 ++ .../Properties/Settings.settings | 3 + 9 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 src/ElectronicPharmacy/Classes/AutoUpdater.cs diff --git a/src/ElectronicPharmacy/App.config b/src/ElectronicPharmacy/App.config index 40c70be..cc6c7be 100644 --- a/src/ElectronicPharmacy/App.config +++ b/src/ElectronicPharmacy/App.config @@ -51,6 +51,9 @@ + + True + \ No newline at end of file diff --git a/src/ElectronicPharmacy/Classes/AppConfig.cs b/src/ElectronicPharmacy/Classes/AppConfig.cs index 6fa57c2..4bf6a89 100644 --- a/src/ElectronicPharmacy/Classes/AppConfig.cs +++ b/src/ElectronicPharmacy/Classes/AppConfig.cs @@ -20,6 +20,27 @@ namespace Электронная_Фармация.Classes /// /// Replaces known dead API URLs in saved user settings. /// + /// + /// Carries user settings across AssemblyVersion bumps (new user.config folder). + /// + public static void UpgradeUserSettingsIfNeeded() + { + try + { + if (Settings.Default.UpgradeSettings) + { + Settings.Default.Upgrade(); + Settings.Default.UpgradeSettings = false; + Settings.Default.Save(); + AppDebugLog.Info("AppConfig", "User settings upgraded from previous version"); + } + } + catch (Exception ex) + { + AppDebugLog.Info("AppConfig", $"Upgrade settings skipped: {ex.Message}"); + } + } + public static void MigrateLegacySettings() { var current = Settings.Default.ApiBaseUrl; diff --git a/src/ElectronicPharmacy/Classes/AutoUpdater.cs b/src/ElectronicPharmacy/Classes/AutoUpdater.cs new file mode 100644 index 0000000..80c2965 --- /dev/null +++ b/src/ElectronicPharmacy/Classes/AutoUpdater.cs @@ -0,0 +1,192 @@ +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)) + { + // Prefer zipball if asset missing + 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 updaterBat = Path.Combine(workRoot, "apply-update.cmd"); + WriteUpdaterScript(updaterBat); + + Report(status, "Подготовка перезапуска..."); + var psi = new ProcessStartInfo + { + FileName = updaterBat, + Arguments = Quote(payloadDir) + " " + Quote(installDir) + " " + + Quote(Path.GetFileName(currentExe)) + " " + + Process.GetCurrentProcess().Id, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = workRoot, + CreateNoWindow = true + }; + Process.Start(psi); + AppDebugLog.Info("AutoUpdater", + $"Updater started. payload={payloadDir}, install={installDir}, ver={update.LatestVersion}"); + } + catch + { + try + { + if (Directory.Exists(workRoot)) + { + Directory.Delete(workRoot, true); + } + } + catch + { + // ignore cleanup errors + } + + throw; + } + } + + 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); + }); + + return exe == null ? null : Path.GetDirectoryName(exe); + } + + private static void WriteUpdaterScript(string path) + { + // Waits for app exit, copies files, keeps local DB/Logs, restarts exe. + var sb = new StringBuilder(); + sb.AppendLine("@echo off"); + sb.AppendLine("setlocal"); + sb.AppendLine("set \"SRC=%~1\""); + sb.AppendLine("set \"DST=%~2\""); + sb.AppendLine("set \"EXENAME=%~3\""); + sb.AppendLine("set \"PID=%~4\""); + sb.AppendLine("echo Waiting for PID %PID% ... > \"%TEMP%\\elfisa-updater.log\""); + sb.AppendLine(":waitloop"); + sb.AppendLine("tasklist /FI \"PID eq %PID%\" 2>nul | find \"%PID%\" >nul"); + sb.AppendLine("if not errorlevel 1 ("); + sb.AppendLine(" timeout /t 1 /nobreak >nul"); + sb.AppendLine(" goto waitloop"); + sb.AppendLine(")"); + sb.AppendLine("timeout /t 1 /nobreak >nul"); + sb.AppendLine("echo Copying update... >> \"%TEMP%\\elfisa-updater.log\""); + // /E copy tree; exclude local DB and logs from being overwritten/removed. + sb.AppendLine("robocopy \"%SRC%\" \"%DST%\" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /XF efClient.db *.db /XD Logs"); + sb.AppendLine("set \"RC=%ERRORLEVEL%\""); + sb.AppendLine("echo robocopy=%RC% >> \"%TEMP%\\elfisa-updater.log\""); + // robocopy 0-7 = success-ish + 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); + } + } +} diff --git a/src/ElectronicPharmacy/ElectronicPharmacy.csproj b/src/ElectronicPharmacy/ElectronicPharmacy.csproj index 176bf79..c3750a0 100644 --- a/src/ElectronicPharmacy/ElectronicPharmacy.csproj +++ b/src/ElectronicPharmacy/ElectronicPharmacy.csproj @@ -98,6 +98,8 @@ + + @@ -106,6 +108,7 @@ + diff --git a/src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.cs b/src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.cs index 85b762a..06c7b89 100644 --- a/src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.cs +++ b/src/ElectronicPharmacy/HelpForms/HF_UpdateAvailable.cs @@ -1,5 +1,5 @@ using System; -using System.Diagnostics; +using System.Threading.Tasks; using System.Windows.Forms; using Электронная_Фармация.Classes; @@ -8,6 +8,7 @@ namespace Электронная_Фармация.HelpForms public partial class HF_UpdateAvailable : Form { private readonly UpdateCheckResult _result; + private bool _updating; public HF_UpdateAvailable(UpdateCheckResult result) { @@ -19,44 +20,78 @@ namespace Электронная_Фармация.HelpForms var local = string.IsNullOrWhiteSpace(_result.LocalVersion) ? "?" : _result.LocalVersion; lblMessage.Text = $"Доступно новое обновление {latest} (у вас {local}).{Environment.NewLine}{Environment.NewLine}" + - "Для продолжения работы необходимо установить новую версию."; + "Нажмите «Обновить» — программа скачает и установит новую версию сама." + Environment.NewLine + + "Локальная база и данные сохранятся."; + btnDownload.Text = "Обновить"; } - private void btnDownload_Click(object sender, EventArgs e) + private async void btnDownload_Click(object sender, EventArgs e) { - var url = !string.IsNullOrWhiteSpace(_result.DownloadUrl) - ? _result.DownloadUrl - : _result.ReleaseUrl; - if (string.IsNullOrWhiteSpace(url)) + if (_updating) + { + return; + } + + if (string.IsNullOrWhiteSpace(_result.DownloadUrl) && + string.IsNullOrWhiteSpace(_result.ReleaseUrl)) { UiDialogs.ShowError("Ссылка на обновление не задана.", "Обновление", this); return; } + _updating = true; + btnDownload.Enabled = false; + btnExit.Enabled = false; + ControlBox = false; + + var progress = new Progress(msg => + { + lblMessage.Text = msg; + }); + try { - Process.Start(new ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }); + await AutoUpdater.ApplyUpdateAsync(_result, progress); + lblMessage.Text = "Обновление скачано. Перезапуск..."; + DialogResult = DialogResult.OK; + Application.Exit(); } catch (Exception ex) { - UiDialogs.ShowError($"Не удалось открыть ссылку:{Environment.NewLine}{ex.Message}", "Обновление", this); + AppDebugLog.Error("Update", "Автообновление не удалось", ex); + _updating = false; + btnDownload.Enabled = true; + btnExit.Enabled = true; + ControlBox = true; + UiDialogs.ShowError( + "Не удалось установить обновление автоматически." + Environment.NewLine + + ex.Message, + "Обновление", + this); } } private void btnExit_Click(object sender, EventArgs e) { + if (_updating) + { + return; + } + DialogResult = DialogResult.Abort; Close(); } private void HF_UpdateAvailable_FormClosing(object sender, FormClosingEventArgs e) { - // Крестик / Alt+F4 — тоже выход из приложения (не пускать в работу). - if (DialogResult != DialogResult.Abort) + if (_updating) + { + e.Cancel = true; + return; + } + + // Крестик / Alt+F4 — выход из приложения (не пускать в работу). + if (DialogResult != DialogResult.Abort && DialogResult != DialogResult.OK) { DialogResult = DialogResult.Abort; } diff --git a/src/ElectronicPharmacy/Program.cs b/src/ElectronicPharmacy/Program.cs index a6db27c..acdf2a2 100644 --- a/src/ElectronicPharmacy/Program.cs +++ b/src/ElectronicPharmacy/Program.cs @@ -12,6 +12,7 @@ namespace Электронная_Фармация { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; AppConfig.MigrateLegacySettings(); + AppConfig.UpgradeUserSettingsIfNeeded(); AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}"); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); diff --git a/src/ElectronicPharmacy/Properties/AssemblyInfo.cs b/src/ElectronicPharmacy/Properties/AssemblyInfo.cs index 17274f1..43a019a 100644 --- a/src/ElectronicPharmacy/Properties/AssemblyInfo.cs +++ b/src/ElectronicPharmacy/Properties/AssemblyInfo.cs @@ -32,5 +32,5 @@ using System.Runtime.InteropServices; // // "*", : // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.11.0")] -[assembly: AssemblyFileVersion("1.0.11.0")] +[assembly: AssemblyVersion("1.0.12.0")] +[assembly: AssemblyFileVersion("1.0.12.0")] diff --git a/src/ElectronicPharmacy/Properties/Settings.Designer.cs b/src/ElectronicPharmacy/Properties/Settings.Designer.cs index d574351..7d62897 100644 --- a/src/ElectronicPharmacy/Properties/Settings.Designer.cs +++ b/src/ElectronicPharmacy/Properties/Settings.Designer.cs @@ -203,5 +203,17 @@ namespace Электронная_Фармация.Properties { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool UpgradeSettings { + get { + return ((bool)(this["UpgradeSettings"])); + } + set { + this["UpgradeSettings"] = value; + } + } + } } diff --git a/src/ElectronicPharmacy/Properties/Settings.settings b/src/ElectronicPharmacy/Properties/Settings.settings index 995ccdf..68532f4 100644 --- a/src/ElectronicPharmacy/Properties/Settings.settings +++ b/src/ElectronicPharmacy/Properties/Settings.settings @@ -47,5 +47,8 @@ + + True + \ No newline at end of file