Add in-app auto-updater that preserves local database.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7497ee2d4f
commit
2b13a7054f
@ -51,6 +51,9 @@
|
|||||||
<setting name="UpdateApiToken" serializeAs="String">
|
<setting name="UpdateApiToken" serializeAs="String">
|
||||||
<value />
|
<value />
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting name="UpgradeSettings" serializeAs="String">
|
||||||
|
<value>True</value>
|
||||||
|
</setting>
|
||||||
</Электронная_Фармация.Properties.Settings>
|
</Электронная_Фармация.Properties.Settings>
|
||||||
</userSettings>
|
</userSettings>
|
||||||
</configuration>
|
</configuration>
|
||||||
@ -20,6 +20,27 @@ namespace Электронная_Фармация.Classes
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces known dead API URLs in saved user settings.
|
/// Replaces known dead API URLs in saved user settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Carries user settings across AssemblyVersion bumps (new user.config folder).
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public static void MigrateLegacySettings()
|
||||||
{
|
{
|
||||||
var current = Settings.Default.ApiBaseUrl;
|
var current = Settings.Default.ApiBaseUrl;
|
||||||
|
|||||||
192
src/ElectronicPharmacy/Classes/AutoUpdater.cs
Normal file
192
src/ElectronicPharmacy/Classes/AutoUpdater.cs
Normal file
@ -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
|
||||||
|
{
|
||||||
|
/// <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))
|
||||||
|
{
|
||||||
|
// 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<string> status, string message)
|
||||||
|
{
|
||||||
|
AppDebugLog.Info("AutoUpdater", message);
|
||||||
|
status?.Report(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -98,6 +98,8 @@
|
|||||||
<Reference Include="System.Data" />
|
<Reference Include="System.Data" />
|
||||||
<Reference Include="System.Deployment" />
|
<Reference Include="System.Deployment" />
|
||||||
<Reference Include="System.Drawing" />
|
<Reference Include="System.Drawing" />
|
||||||
|
<Reference Include="System.IO.Compression" />
|
||||||
|
<Reference Include="System.IO.Compression.FileSystem" />
|
||||||
<Reference Include="System.Net.Http" />
|
<Reference Include="System.Net.Http" />
|
||||||
<Reference Include="System.Windows.Forms" />
|
<Reference Include="System.Windows.Forms" />
|
||||||
<Reference Include="System.Xml" />
|
<Reference Include="System.Xml" />
|
||||||
@ -106,6 +108,7 @@
|
|||||||
<Compile Include="Classes\ApiClient.cs" />
|
<Compile Include="Classes\ApiClient.cs" />
|
||||||
<Compile Include="Classes\AppConfig.cs" />
|
<Compile Include="Classes\AppConfig.cs" />
|
||||||
<Compile Include="Classes\AppDebugLog.cs" />
|
<Compile Include="Classes\AppDebugLog.cs" />
|
||||||
|
<Compile Include="Classes\AutoUpdater.cs" />
|
||||||
<Compile Include="Classes\ConsigneeHelper.cs" />
|
<Compile Include="Classes\ConsigneeHelper.cs" />
|
||||||
<Compile Include="Classes\SqlSyntaxHighlighter.cs" />
|
<Compile Include="Classes\SqlSyntaxHighlighter.cs" />
|
||||||
<Compile Include="Classes\DataSender.cs" />
|
<Compile Include="Classes\DataSender.cs" />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using Электронная_Фармация.Classes;
|
using Электронная_Фармация.Classes;
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ namespace Электронная_Фармация.HelpForms
|
|||||||
public partial class HF_UpdateAvailable : Form
|
public partial class HF_UpdateAvailable : Form
|
||||||
{
|
{
|
||||||
private readonly UpdateCheckResult _result;
|
private readonly UpdateCheckResult _result;
|
||||||
|
private bool _updating;
|
||||||
|
|
||||||
public HF_UpdateAvailable(UpdateCheckResult result)
|
public HF_UpdateAvailable(UpdateCheckResult result)
|
||||||
{
|
{
|
||||||
@ -19,44 +20,78 @@ namespace Электронная_Фармация.HelpForms
|
|||||||
var local = string.IsNullOrWhiteSpace(_result.LocalVersion) ? "?" : _result.LocalVersion;
|
var local = string.IsNullOrWhiteSpace(_result.LocalVersion) ? "?" : _result.LocalVersion;
|
||||||
lblMessage.Text =
|
lblMessage.Text =
|
||||||
$"Доступно новое обновление {latest} (у вас {local}).{Environment.NewLine}{Environment.NewLine}" +
|
$"Доступно новое обновление {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)
|
if (_updating)
|
||||||
? _result.DownloadUrl
|
{
|
||||||
: _result.ReleaseUrl;
|
return;
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_result.DownloadUrl) &&
|
||||||
|
string.IsNullOrWhiteSpace(_result.ReleaseUrl))
|
||||||
{
|
{
|
||||||
UiDialogs.ShowError("Ссылка на обновление не задана.", "Обновление", this);
|
UiDialogs.ShowError("Ссылка на обновление не задана.", "Обновление", this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updating = true;
|
||||||
|
btnDownload.Enabled = false;
|
||||||
|
btnExit.Enabled = false;
|
||||||
|
ControlBox = false;
|
||||||
|
|
||||||
|
var progress = new Progress<string>(msg =>
|
||||||
|
{
|
||||||
|
lblMessage.Text = msg;
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Process.Start(new ProcessStartInfo
|
await AutoUpdater.ApplyUpdateAsync(_result, progress);
|
||||||
{
|
lblMessage.Text = "Обновление скачано. Перезапуск...";
|
||||||
FileName = url,
|
DialogResult = DialogResult.OK;
|
||||||
UseShellExecute = true
|
Application.Exit();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private void btnExit_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
if (_updating)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DialogResult = DialogResult.Abort;
|
DialogResult = DialogResult.Abort;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HF_UpdateAvailable_FormClosing(object sender, FormClosingEventArgs e)
|
private void HF_UpdateAvailable_FormClosing(object sender, FormClosingEventArgs e)
|
||||||
{
|
{
|
||||||
// Крестик / Alt+F4 — тоже выход из приложения (не пускать в работу).
|
if (_updating)
|
||||||
if (DialogResult != DialogResult.Abort)
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Крестик / Alt+F4 — выход из приложения (не пускать в работу).
|
||||||
|
if (DialogResult != DialogResult.Abort && DialogResult != DialogResult.OK)
|
||||||
{
|
{
|
||||||
DialogResult = DialogResult.Abort;
|
DialogResult = DialogResult.Abort;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ namespace Электронная_Фармация
|
|||||||
{
|
{
|
||||||
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
|
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
|
||||||
AppConfig.MigrateLegacySettings();
|
AppConfig.MigrateLegacySettings();
|
||||||
|
AppConfig.UpgradeUserSettingsIfNeeded();
|
||||||
AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}");
|
AppDebugLog.Info("App", $"Запуск. ApiBaseUrl={AppConfig.ApiBaseUrl}, Log={AppDebugLog.CurrentLogFilePath}");
|
||||||
Application.EnableVisualStyles();
|
Application.EnableVisualStyles();
|
||||||
Application.SetCompatibleTextRenderingDefault(false);
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
|||||||
@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
|
|||||||
// Можно задать все значения или принять номера сборки и редакции по умолчанию
|
// Можно задать все значения или принять номера сборки и редакции по умолчанию
|
||||||
// используя "*", как показано ниже:
|
// используя "*", как показано ниже:
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
[assembly: AssemblyVersion("1.0.11.0")]
|
[assembly: AssemblyVersion("1.0.12.0")]
|
||||||
[assembly: AssemblyFileVersion("1.0.11.0")]
|
[assembly: AssemblyFileVersion("1.0.12.0")]
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,5 +47,8 @@
|
|||||||
<Setting Name="UpdateApiToken" Type="System.String" Scope="User">
|
<Setting Name="UpdateApiToken" Type="System.String" Scope="User">
|
||||||
<Value Profile="(Default)" />
|
<Value Profile="(Default)" />
|
||||||
</Setting>
|
</Setting>
|
||||||
|
<Setting Name="UpgradeSettings" Type="System.Boolean" Scope="User">
|
||||||
|
<Value Profile="(Default)">True</Value>
|
||||||
|
</Setting>
|
||||||
</Settings>
|
</Settings>
|
||||||
</SettingsFile>
|
</SettingsFile>
|
||||||
Loading…
Reference in New Issue
Block a user