версия 2.0.4: авто-обновление в Avalonia-клиенте
- UpdateService (Core): проверка Gitea releases/latest + скачивание установщика с CDN - UpdateWindow/VM: уведомление «доступно обновление», тихая установка поверх с перезапуском - ShellView: проверка при старте (один раз, офлайн/ошибки игнорируются) Закрывает пробел: Avalonia-клиент раньше не умел обновляться (апдейтер был только в WinForms). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
436bbb8bfb
commit
e8357bab1e
@ -3,7 +3,7 @@
|
||||
|
||||
#define MyAppName "Электронная Фармация"
|
||||
#define MyAppBrand "ЭльФиСА"
|
||||
#define MyAppVersion "2.0.3"
|
||||
#define MyAppVersion "2.0.4"
|
||||
#define MyAppPublisher "PharmData"
|
||||
#define MyAppURL "https://cdn.24pharmdata.ru"
|
||||
#define MyAppSupportURL "https://24pharmdata.ru"
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<Version>2.0.3</Version>
|
||||
<AssemblyVersion>2.0.3.0</AssemblyVersion>
|
||||
<FileVersion>2.0.3.0</FileVersion>
|
||||
<Version>2.0.4</Version>
|
||||
<AssemblyVersion>2.0.4.0</AssemblyVersion>
|
||||
<FileVersion>2.0.4.0</FileVersion>
|
||||
<Product>ЭльФиСА — Электронная Фармация</Product>
|
||||
<Company>PharmData</Company>
|
||||
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
||||
|
||||
95
src/Elfisa.Avalonia/ViewModels/UpdateViewModel.cs
Normal file
95
src/Elfisa.Avalonia/ViewModels/UpdateViewModel.cs
Normal file
@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>Окно «Доступно обновление»: скачивает установщик и запускает его.</summary>
|
||||
public partial class UpdateViewModel : ViewModelBase
|
||||
{
|
||||
private readonly UpdateInfo _info;
|
||||
private readonly Action _close;
|
||||
|
||||
public UpdateViewModel(UpdateInfo info, Action close)
|
||||
{
|
||||
_info = info;
|
||||
_close = close;
|
||||
}
|
||||
|
||||
public string Message =>
|
||||
$"Установлена версия {_info.CurrentVersion}.\nДоступна новая версия {_info.LatestVersion}.";
|
||||
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private double _progress;
|
||||
[ObservableProperty] private string _status = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void Later() => _close();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UpdateAsync()
|
||||
{
|
||||
// Не-Windows: установщика нет — открываем страницу релиза в браузере.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
OpenUrl(string.IsNullOrWhiteSpace(_info.ReleaseUrl) ? "https://cdn.24pharmdata.ru" : _info.ReleaseUrl);
|
||||
_close();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Busy = true;
|
||||
Status = "Скачивание обновления…";
|
||||
var progress = new Progress<double>(p =>
|
||||
{
|
||||
Progress = p;
|
||||
Status = $"Скачивание обновления… {p:0}%";
|
||||
});
|
||||
var setupPath = await UpdateService.DownloadSetupAsync(progress);
|
||||
|
||||
Status = "Запуск установки…";
|
||||
LaunchInstallerAndExit(setupPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Busy = false;
|
||||
Status = "Не удалось обновить: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Тихая установка поверх + перезапуск приложения, затем закрываем себя.</summary>
|
||||
private void LaunchInstallerAndExit(string setupPath)
|
||||
{
|
||||
var exe = Process.GetCurrentProcess().MainModule?.FileName;
|
||||
var inner = $"\"{setupPath}\" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART";
|
||||
if (!string.IsNullOrEmpty(exe))
|
||||
inner += $" && start \"\" \"{exe}\"";
|
||||
|
||||
Process.Start(new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
Arguments = "/c \"" + inner + "\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
// Закрываем приложение, чтобы установщик заменил файлы (CloseApplications=yes).
|
||||
if (global::Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
|
||||
d.Shutdown();
|
||||
else
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
private static void OpenUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch { /* игнорируем */ }
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,25 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class ShellView : UserControl
|
||||
{
|
||||
public ShellView() => AvaloniaXamlLoader.Load(this);
|
||||
private static bool _updateChecked; // один раз за запуск
|
||||
|
||||
public ShellView()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnLoaded;
|
||||
}
|
||||
|
||||
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@ -18,4 +28,28 @@ public partial class ShellView : UserControl
|
||||
if (owner is not null) await win.ShowDialog(owner);
|
||||
else win.Show();
|
||||
}
|
||||
|
||||
// Проверка обновлений при первом появлении шелла. Тихо игнорируем офлайн/ошибки.
|
||||
private async void OnLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_updateChecked) return;
|
||||
_updateChecked = true;
|
||||
|
||||
try
|
||||
{
|
||||
var current = Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
|
||||
var info = await UpdateService.CheckAsync(current);
|
||||
if (info is not { Available: true }) return;
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var win = new UpdateWindow();
|
||||
win.DataContext = new UpdateViewModel(info, win.Close);
|
||||
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
if (owner is not null) await win.ShowDialog(owner);
|
||||
else win.Show();
|
||||
});
|
||||
}
|
||||
catch { /* обновление не должно мешать работе */ }
|
||||
}
|
||||
}
|
||||
|
||||
28
src/Elfisa.Avalonia/Views/UpdateWindow.axaml
Normal file
28
src/Elfisa.Avalonia/Views/UpdateWindow.axaml
Normal file
@ -0,0 +1,28 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.UpdateWindow"
|
||||
x:DataType="vm:UpdateViewModel"
|
||||
Width="440" Height="240" CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource Bg}"
|
||||
Title="Обновление">
|
||||
|
||||
<StackPanel Margin="24" Spacing="16">
|
||||
<TextBlock Text="Доступно обновление" Classes="h1"/>
|
||||
|
||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
|
||||
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource Muted}"
|
||||
TextWrapping="Wrap" IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,4,0,0">
|
||||
<Button Content="Позже" Command="{Binding LaterCommand}" IsEnabled="{Binding !Busy}" Padding="18,9"/>
|
||||
<Button Classes="primary" Content="Обновить" Command="{Binding UpdateCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
9
src/Elfisa.Avalonia/Views/UpdateWindow.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/UpdateWindow.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class UpdateWindow : Window
|
||||
{
|
||||
public UpdateWindow() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
96
src/Elfisa.Core/UpdateService.cs
Normal file
96
src/Elfisa.Core/UpdateService.cs
Normal file
@ -0,0 +1,96 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>Результат проверки обновления.</summary>
|
||||
public sealed class UpdateInfo
|
||||
{
|
||||
public bool Available { get; init; }
|
||||
public string CurrentVersion { get; init; } = "";
|
||||
public string LatestVersion { get; init; } = "";
|
||||
public string ReleaseUrl { get; init; } = "";
|
||||
public string SetupUrl { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверка и скачивание обновлений Avalonia-клиента.
|
||||
/// Смотрит в Gitea Releases (тот же репозиторий, что и старый WinForms-апдейтер),
|
||||
/// установщик тянет с CDN. Никаких токенов — репозиторий и CDN публичны.
|
||||
/// </summary>
|
||||
public static class UpdateService
|
||||
{
|
||||
private const string ReleasesLatest =
|
||||
"https://git.24pharmdata.ru/api/v1/repos/pharmdata/elfisa-pharmacy/releases/latest";
|
||||
|
||||
/// <summary>Стабильный URL установщика на CDN (всегда последняя версия).</summary>
|
||||
public const string SetupUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
|
||||
|
||||
/// <summary>Проверяет наличие новой версии. Возвращает null при офлайне/ошибке (не мешаем работе).</summary>
|
||||
public static async Task<UpdateInfo?> CheckAsync(Version current)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
|
||||
http.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
var json = await http.GetStringAsync(ReleasesLatest).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null;
|
||||
var latest = ParseVersion(tag);
|
||||
if (latest is null) return null;
|
||||
var htmlUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null;
|
||||
return new UpdateInfo
|
||||
{
|
||||
Available = latest > current,
|
||||
CurrentVersion = current.ToString(),
|
||||
LatestVersion = latest.ToString(),
|
||||
ReleaseUrl = htmlUrl ?? "",
|
||||
SetupUrl = SetupUrl,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Скачивает установщик с CDN во временный файл. Возвращает путь.</summary>
|
||||
public static async Task<string> DownloadSetupAsync(IProgress<double>? progress, CancellationToken ct = default)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "ElfisaPharmacy-Setup.exe");
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
using var resp = await http.GetAsync(SetupUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var total = resp.Content.Headers.ContentLength ?? -1;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await using var dst = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
var buffer = new byte[81920];
|
||||
long readTotal = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||
readTotal += n;
|
||||
if (total > 0) progress?.Report(readTotal * 100.0 / total);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version.</summary>
|
||||
private static Version? ParseVersion(string? tag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag)) return null;
|
||||
tag = tag.Trim();
|
||||
if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase)) tag = tag[1..];
|
||||
var dash = tag.IndexOf('-');
|
||||
if (dash > 0) tag = tag[..dash];
|
||||
switch (tag.Split('.').Length)
|
||||
{
|
||||
case 1: tag += ".0.0.0"; break;
|
||||
case 2: tag += ".0.0"; break;
|
||||
case 3: tag += ".0"; break;
|
||||
}
|
||||
return Version.TryParse(tag, out var v) ? v : null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user