diff --git a/README.md b/README.md index a4e50cd..c681cc1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,18 @@ Wichtig: 5. Im Tab `Allgemein` Polling aktivieren und Intervall setzen. 6. Mit `Jetzt prüfen` kann sofort ein Poll-Lauf ausgelöst werden; dabei werden alle aktuell verfügbaren Jobs verarbeitet. +## Updates + +Im Tab `Updates` kann der Agent gegen die Gitea-Release-API nach neuen Versionen suchen. Standardmäßig wird das neueste Release dieses Repositorys abgefragt: + +```text +https://gitea.poettker-cloud.de/api/v1/repos/bjoernpoettker/LabelPrintAgent/releases/latest +``` + +Für private Repositories kann ein Gitea-Access-Token hinterlegt werden. Der Token wird lokal verschlüsselt gespeichert. + +Wenn ein neueres Release gefunden wird, sucht der Agent nach einem ZIP-Asset, bevorzugt mit dem Suffix `-win-x64.zip`. Beim Installieren lädt der Agent das ZIP herunter, entpackt es in ein temporäres Verzeichnis, startet ein lokales Update-Skript, beendet sich selbst, ersetzt die Dateien im Installationsordner und startet anschließend neu. + ## Tray-Status Das Tray-Icon zeigt den aktuellen Zustand: diff --git a/src/LabelPrintAgent/App/Program.cs b/src/LabelPrintAgent/App/Program.cs index 8ff92ff..270ee9e 100644 --- a/src/LabelPrintAgent/App/Program.cs +++ b/src/LabelPrintAgent/App/Program.cs @@ -2,6 +2,7 @@ using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; using LabelPrintAgent.Logging; using LabelPrintAgent.Printing; +using LabelPrintAgent.Updates; using Serilog; namespace LabelPrintAgent.App; @@ -23,10 +24,11 @@ internal static class Program var printerService = new PrinterService(); var backendClient = new BackendClient(settingsStore); + var updateService = new UpdateService(settingsStore); using var backendWorker = new BackendPollingWorker(settingsStore, backendClient, printerService); backendWorker.Start(); - Application.Run(new TrayApplicationContext(settingsStore, printerService, backendWorker)); + Application.Run(new TrayApplicationContext(settingsStore, printerService, backendWorker, updateService)); } catch (Exception ex) { diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index ff29dcc..8238596 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -1,6 +1,7 @@ using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; using LabelPrintAgent.Printing; +using LabelPrintAgent.Updates; using Microsoft.Win32; using Serilog; @@ -14,6 +15,8 @@ internal sealed class SettingsForm : Form private readonly SettingsStore _settingsStore; private readonly PrinterService _printerService; private readonly BackendPollingWorker _backendWorker; + private readonly UpdateService _updateService; + private UpdateCheckResult? _lastUpdateCheck; private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true }; private readonly CheckBox _workerEnabled = new() { Text = "Backend automatisch abfragen", AutoSize = true }; @@ -30,6 +33,13 @@ internal sealed class SettingsForm : Form private readonly CheckBox _useServerSentEvents = new() { Text = "Server-Sent Events verwenden", AutoSize = true }; private readonly TextBox _eventsPath = new() { Width = 420 }; + private readonly TextBox _releaseApiUrl = new() { Width = 540 }; + private readonly TextBox _releaseToken = new() { Width = 420, UseSystemPasswordChar = true }; + private readonly TextBox _currentVersion = new() { Width = 180, ReadOnly = true }; + private readonly TextBox _latestVersion = new() { Width = 180, ReadOnly = true }; + private readonly TextBox _updateAsset = new() { Width = 540, ReadOnly = true }; + private readonly Button _installUpdateButton; + private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 }; private readonly NumericUpDown _labelHeight = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 32, Width = 100 }; @@ -42,11 +52,18 @@ internal sealed class SettingsForm : Form Dock = DockStyle.Fill }; - public SettingsForm(SettingsStore settingsStore, PrinterService printerService, BackendPollingWorker backendWorker) + public SettingsForm( + SettingsStore settingsStore, + PrinterService printerService, + BackendPollingWorker backendWorker, + UpdateService updateService) { _settingsStore = settingsStore; _printerService = printerService; _backendWorker = backendWorker; + _updateService = updateService; + _installUpdateButton = ButtonAt("Update installieren", 340, 265, async (_, _) => await InstallUpdateFromUiAsync()); + _installUpdateButton.Enabled = false; Text = "LabelPrintAgent Einstellungen"; Width = 900; @@ -57,6 +74,7 @@ internal sealed class SettingsForm : Form tabs.TabPages.Add(CreateGeneralTab()); tabs.TabPages.Add(CreateBackendTab()); tabs.TabPages.Add(CreatePrinterTab()); + tabs.TabPages.Add(CreateUpdatesTab()); tabs.TabPages.Add(CreateStatusTab()); Controls.Add(tabs); @@ -106,6 +124,20 @@ internal sealed class SettingsForm : Form return new TabPage("Drucker") { Controls = { panel } }; } + private TabPage CreateUpdatesTab() + { + var panel = CreatePaddedPanel(); + panel.Controls.Add(Row(20, Label("Aktuelle Version", 160), _currentVersion)); + panel.Controls.Add(Row(60, Label("Neueste Version", 160), _latestVersion)); + panel.Controls.Add(Row(100, Label("Release API", 160), _releaseApiUrl)); + panel.Controls.Add(Row(140, Label("Access Token", 160), _releaseToken)); + panel.Controls.Add(Row(180, Label("Installationsdatei", 160), _updateAsset)); + panel.Controls.Add(ButtonAt("Speichern", 180, 265, (_, _) => SaveUpdates(showMessage: true))); + panel.Controls.Add(ButtonAt("Nach Updates suchen", 180, 315, async (_, _) => await CheckForUpdateFromUiAsync())); + panel.Controls.Add(_installUpdateButton); + return new TabPage("Updates") { Controls = { panel } }; + } + private TabPage CreateStatusTab() { var page = new TabPage("Status"); @@ -131,6 +163,14 @@ internal sealed class SettingsForm : Form _useServerSentEvents.Checked = settings.Backend.UseServerSentEvents; _eventsPath.Text = settings.Backend.EventsPath; + _releaseApiUrl.Text = settings.Updates.ReleaseApiUrl; + _releaseToken.Text = _settingsStore.DecryptUpdateAccessToken(settings); + _currentVersion.Text = _updateService.CurrentVersion.ToString(); + _latestVersion.Text = string.Empty; + _updateAsset.Text = string.Empty; + _installUpdateButton.Enabled = false; + _lastUpdateCheck = null; + _labelWidth.Value = settings.Printer.LabelWidthMm; _labelHeight.Value = settings.Printer.LabelHeightMm; } @@ -199,6 +239,19 @@ internal sealed class SettingsForm : Form } } + private void SaveUpdates(bool showMessage) + { + var settings = _settingsStore.Load(); + settings.Updates.ReleaseApiUrl = _releaseApiUrl.Text.Trim(); + settings.Updates.EncryptedAccessToken = _settingsStore.EncryptPassword(_releaseToken.Text.Trim()); + _settingsStore.Save(settings); + AppendStatus("Update-Einstellungen gespeichert."); + if (showMessage) + { + MessageBox.Show("Update-Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + private async Task PollOnceFromUiAsync() { try @@ -217,6 +270,83 @@ internal sealed class SettingsForm : Form } } + private async Task CheckForUpdateFromUiAsync() + { + try + { + SaveUpdates(showMessage: false); + _installUpdateButton.Enabled = false; + _lastUpdateCheck = null; + _latestVersion.Text = string.Empty; + _updateAsset.Text = string.Empty; + + AppendStatus("Suche nach neuen Releases..."); + var result = await _updateService.CheckForUpdateAsync(); + _lastUpdateCheck = result; + _currentVersion.Text = result.CurrentVersion.ToString(); + _latestVersion.Text = result.Release.TagName; + _updateAsset.Text = result.InstallAsset?.Name ?? "Kein ZIP-Asset gefunden"; + + if (!result.IsUpdateAvailable) + { + AppendStatus($"Kein Update verfügbar. Installiert: {result.CurrentVersion}, Release: {result.Release.TagName}."); + MessageBox.Show("Es ist kein Update verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + if (result.InstallAsset is null) + { + AppendStatus($"Update {result.Release.TagName} gefunden, aber kein ZIP-Asset zum Installieren."); + MessageBox.Show("Update gefunden, aber das Release enthält kein ZIP-Asset.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + _installUpdateButton.Enabled = true; + AppendStatus($"Update {result.Release.TagName} gefunden: {result.InstallAsset.Name}"); + MessageBox.Show($"Update {result.Release.TagName} ist verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + Log.Error(ex, "Update check failed"); + AppendStatus($"Update-Prüfung fehlgeschlagen: {ex.Message}"); + MessageBox.Show($"Update-Prüfung fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async Task InstallUpdateFromUiAsync() + { + if (_lastUpdateCheck is null || _lastUpdateCheck.InstallAsset is null) + { + MessageBox.Show("Bitte zuerst nach Updates suchen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var confirmation = MessageBox.Show( + $"Update {_lastUpdateCheck.Release.TagName} installieren? Die Anwendung wird danach neu gestartet.", + Text, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (confirmation != DialogResult.Yes) + { + return; + } + + try + { + AppendStatus($"Update {_lastUpdateCheck.Release.TagName} wird heruntergeladen..."); + await _updateService.InstallUpdateAsync(_lastUpdateCheck); + AppendStatus("Update-Skript gestartet. Anwendung wird beendet."); + Application.Exit(); + } + catch (Exception ex) + { + Log.Error(ex, "Update installation failed"); + AppendStatus($"Update-Installation fehlgeschlagen: {ex.Message}"); + MessageBox.Show($"Update-Installation fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + private string? GetSelectedPrinterName() { return _printer.SelectedItem switch diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index c97b477..8c6a904 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -1,6 +1,7 @@ using LabelPrintAgent.Backend; using LabelPrintAgent.Configuration; using LabelPrintAgent.Printing; +using LabelPrintAgent.Updates; using Serilog; namespace LabelPrintAgent.App; @@ -11,6 +12,7 @@ internal sealed class TrayApplicationContext : ApplicationContext private readonly SettingsStore _settingsStore; private readonly PrinterService _printerService; private readonly BackendPollingWorker _backendWorker; + private readonly UpdateService _updateService; private readonly Icon _healthyIcon; private readonly Icon _unhealthyIcon; private readonly System.Windows.Forms.Timer _statusTimer; @@ -19,11 +21,13 @@ internal sealed class TrayApplicationContext : ApplicationContext public TrayApplicationContext( SettingsStore settingsStore, PrinterService printerService, - BackendPollingWorker backendWorker) + BackendPollingWorker backendWorker, + UpdateService updateService) { _settingsStore = settingsStore; _printerService = printerService; _backendWorker = backendWorker; + _updateService = updateService; _healthyIcon = TrayIconFactory.CreatePrinterIcon(Color.FromArgb(36, 160, 72)); _unhealthyIcon = TrayIconFactory.CreatePrinterIcon(Color.FromArgb(210, 48, 48)); @@ -58,7 +62,7 @@ internal sealed class TrayApplicationContext : ApplicationContext { if (_settingsForm is null || _settingsForm.IsDisposed) { - _settingsForm = new SettingsForm(_settingsStore, _printerService, _backendWorker); + _settingsForm = new SettingsForm(_settingsStore, _printerService, _backendWorker, _updateService); } _settingsForm.Show(); diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs index 865fb4f..936d44c 100644 --- a/src/LabelPrintAgent/Configuration/AppSettings.cs +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -3,6 +3,7 @@ namespace LabelPrintAgent.Configuration; public sealed class AppSettings { public BackendSettings Backend { get; set; } = new(); + public UpdateSettings Updates { get; set; } = new(); public PrinterSettings Printer { get; set; } = new(); public WorkerSettings Worker { get; set; } = new(); public PathSettings Paths { get; set; } = PathSettings.CreateDefault(); @@ -27,6 +28,13 @@ public sealed class BackendSettings public string EventsPath { get; set; } = "/api/label-print-agent/events"; } +public sealed class UpdateSettings +{ + public string ReleaseApiUrl { get; set; } = + "https://gitea.poettker-cloud.de/api/v1/repos/bjoernpoettker/LabelPrintAgent/releases/latest"; + public string EncryptedAccessToken { get; set; } = string.Empty; +} + public sealed class PrinterSettings { public string PrinterName { get; set; } = string.Empty; diff --git a/src/LabelPrintAgent/Configuration/SettingsStore.cs b/src/LabelPrintAgent/Configuration/SettingsStore.cs index 4ae2c04..3d43c45 100644 --- a/src/LabelPrintAgent/Configuration/SettingsStore.cs +++ b/src/LabelPrintAgent/Configuration/SettingsStore.cs @@ -38,6 +38,10 @@ public sealed class SettingsStore var json = File.ReadAllText(SettingsFilePath); var settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); settings.Paths ??= PathSettings.CreateDefault(); + settings.Backend ??= new BackendSettings(); + settings.Updates ??= new UpdateSettings(); + settings.Printer ??= new PrinterSettings(); + settings.Worker ??= new WorkerSettings(); return settings; } catch (Exception ex) @@ -58,6 +62,8 @@ public sealed class SettingsStore public string DecryptBackendApiToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Backend.EncryptedApiToken); + public string DecryptUpdateAccessToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Updates.EncryptedAccessToken); + public static string NormalizeBackendApiToken(string token) { if (string.IsNullOrWhiteSpace(token)) diff --git a/src/LabelPrintAgent/Updates/ReleaseAsset.cs b/src/LabelPrintAgent/Updates/ReleaseAsset.cs new file mode 100644 index 0000000..7c738fc --- /dev/null +++ b/src/LabelPrintAgent/Updates/ReleaseAsset.cs @@ -0,0 +1,3 @@ +namespace LabelPrintAgent.Updates; + +internal sealed record ReleaseAsset(string Name, string DownloadUrl, long Size); diff --git a/src/LabelPrintAgent/Updates/ReleaseInfo.cs b/src/LabelPrintAgent/Updates/ReleaseInfo.cs new file mode 100644 index 0000000..d25820d --- /dev/null +++ b/src/LabelPrintAgent/Updates/ReleaseInfo.cs @@ -0,0 +1,16 @@ +namespace LabelPrintAgent.Updates; + +internal sealed record ReleaseInfo( + string TagName, + string Name, + string HtmlUrl, + IReadOnlyList Assets) +{ + public ReleaseAsset? FindWindowsZipAsset() + { + return Assets.FirstOrDefault(asset => + asset.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase)) + ?? Assets.FirstOrDefault(asset => + asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/LabelPrintAgent/Updates/UpdateCheckResult.cs b/src/LabelPrintAgent/Updates/UpdateCheckResult.cs new file mode 100644 index 0000000..cbde011 --- /dev/null +++ b/src/LabelPrintAgent/Updates/UpdateCheckResult.cs @@ -0,0 +1,9 @@ +namespace LabelPrintAgent.Updates; + +internal sealed record UpdateCheckResult( + Version CurrentVersion, + ReleaseInfo Release, + bool IsUpdateAvailable) +{ + public ReleaseAsset? InstallAsset => Release.FindWindowsZipAsset(); +} diff --git a/src/LabelPrintAgent/Updates/UpdateService.cs b/src/LabelPrintAgent/Updates/UpdateService.cs new file mode 100644 index 0000000..fa60e23 --- /dev/null +++ b/src/LabelPrintAgent/Updates/UpdateService.cs @@ -0,0 +1,222 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using LabelPrintAgent.Configuration; + +namespace LabelPrintAgent.Updates; + +internal sealed class UpdateService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly SettingsStore _settingsStore; + + public UpdateService(SettingsStore settingsStore) + { + _settingsStore = settingsStore; + } + + public Version CurrentVersion => GetCurrentVersion(); + + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + var settings = _settingsStore.Load(); + if (string.IsNullOrWhiteSpace(settings.Updates.ReleaseApiUrl)) + { + throw new InvalidOperationException("Die Release-API-URL fehlt."); + } + + using var client = CreateHttpClient(settings); + using var response = await client.GetAsync(settings.Updates.ReleaseApiUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var release = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + ?? throw new InvalidOperationException("Die Release-Antwort konnte nicht gelesen werden."); + + var latest = MapRelease(release); + var latestVersion = ParseVersion(latest.TagName); + var currentVersion = CurrentVersion; + return new UpdateCheckResult(currentVersion, latest, latestVersion > currentVersion); + } + + public async Task InstallUpdateAsync(UpdateCheckResult checkResult, CancellationToken cancellationToken = default) + { + var asset = checkResult.InstallAsset + ?? throw new InvalidOperationException("Das Release enthält kein installierbares ZIP-Asset."); + + var installFolder = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var executablePath = Application.ExecutablePath; + var updateRoot = Path.Combine(Path.GetTempPath(), "LabelPrintAgent", "updates", checkResult.Release.TagName); + var downloadFile = Path.Combine(updateRoot, asset.Name); + var extractFolder = Path.Combine(updateRoot, "extracted"); + var updaterScript = Path.Combine(updateRoot, "apply-update.cmd"); + + Directory.CreateDirectory(updateRoot); + if (Directory.Exists(extractFolder)) + { + Directory.Delete(extractFolder, recursive: true); + } + + Directory.CreateDirectory(extractFolder); + + var settings = _settingsStore.Load(); + using var client = CreateHttpClient(settings); + await using (var source = await client.GetStreamAsync(asset.DownloadUrl, cancellationToken)) + await using (var target = File.Create(downloadFile)) + { + await source.CopyToAsync(target, cancellationToken); + } + + ZipFile.ExtractToDirectory(downloadFile, extractFolder, overwriteFiles: true); + WriteUpdaterScript(updaterScript, extractFolder, installFolder, executablePath, Environment.ProcessId); + StartUpdater(updaterScript); + } + + private HttpClient CreateHttpClient(AppSettings settings) + { + var client = new HttpClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("LabelPrintAgent"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var token = _settingsStore.DecryptUpdateAccessToken(settings).Trim(); + if (!string.IsNullOrWhiteSpace(token)) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); + } + + return client; + } + + private static ReleaseInfo MapRelease(GiteaReleaseResponse release) + { + var assets = release.Assets + .Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && !string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) + .Select(asset => new ReleaseAsset(asset.Name, asset.BrowserDownloadUrl, asset.Size)) + .ToList(); + + if (string.IsNullOrWhiteSpace(release.TagName)) + { + throw new InvalidOperationException("Das Release enthält keinen Tag."); + } + + return new ReleaseInfo( + release.TagName, + string.IsNullOrWhiteSpace(release.Name) ? release.TagName : release.Name, + release.HtmlUrl, + assets); + } + + private static Version GetCurrentVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var informational = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informational)) + { + return ParseVersion(informational); + } + + return assembly.GetName().Version ?? new Version(0, 0, 0); + } + + private static Version ParseVersion(string value) + { + var cleaned = value.Trim(); + if (cleaned.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + cleaned = cleaned[1..]; + } + + var metadataIndex = cleaned.IndexOf('+', StringComparison.Ordinal); + if (metadataIndex >= 0) + { + cleaned = cleaned[..metadataIndex]; + } + + var prereleaseIndex = cleaned.IndexOf('-', StringComparison.Ordinal); + if (prereleaseIndex >= 0) + { + cleaned = cleaned[..prereleaseIndex]; + } + + return Version.TryParse(cleaned, out var version) + ? version + : new Version(0, 0, 0); + } + + private static void WriteUpdaterScript( + string scriptPath, + string sourceFolder, + string targetFolder, + string executablePath, + int processId) + { + var script = $""" + @echo off + setlocal + set "SOURCE={sourceFolder}" + set "TARGET={targetFolder}" + set "EXE={executablePath}" + set "PID={processId}" + + :wait + tasklist /FI "PID eq %PID%" 2>NUL | find "%PID%" >NUL + if not errorlevel 1 ( + timeout /t 1 /nobreak >NUL + goto wait + ) + + xcopy "%SOURCE%\*" "%TARGET%\" /E /I /Y >NUL + start "" "%EXE%" + del "%~f0" + """; + + File.WriteAllText(scriptPath, script); + } + + private static void StartUpdater(string scriptPath) + { + Process.Start(new ProcessStartInfo + { + FileName = scriptPath, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden + }); + } + + private sealed class GiteaReleaseResponse + { + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("html_url")] + public string HtmlUrl { get; set; } = string.Empty; + + [JsonPropertyName("assets")] + public List Assets { get; set; } = []; + } + + private sealed class GiteaReleaseAssetResponse + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("browser_download_url")] + public string BrowserDownloadUrl { get; set; } = string.Empty; + + [JsonPropertyName("size")] + public long Size { get; set; } + } +}