feat(app): check GitHub releases on startup and prompt for updates
Compares the running assembly version against the latest GitHub release tag on every app launch (desktop and Android) and shows an in-app overlay with the new version tag when one is available. Best-effort: network/parse failures are silent, matching the app's existing non-blocking startup checks.
This commit is contained in:
@@ -63,6 +63,10 @@ public sealed class Loc
|
|||||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||||
|
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
||||||
|
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||||
|
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||||
|
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
||||||
["donate.desc"] = [
|
["donate.desc"] = [
|
||||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||||
"If you find this wallet useful, consider a small donation to the developer.",
|
"If you find this wallet useful, consider a small donation to the developer.",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainWindowViewModel
|
||||||
|
{
|
||||||
|
// ---- update-available overlay ----
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isUpdateAvailableOpen;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string updateAvailableTag = "";
|
||||||
|
|
||||||
|
public string UpdateReleaseUrl { get; private set; } = "";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||||
|
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||||
|
/// </summary>
|
||||||
|
private async Task CheckForUpdatesAsync()
|
||||||
|
{
|
||||||
|
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
||||||
|
if (latest is null) return;
|
||||||
|
|
||||||
|
UpdateReleaseUrl = latest.HtmlUrl;
|
||||||
|
UpdateAvailableTag = latest.Tag;
|
||||||
|
IsUpdateAvailableOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||||
_keepAliveTimer.Start();
|
_keepAliveTimer.Start();
|
||||||
|
_ = CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||||
|
|||||||
@@ -1638,5 +1638,34 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ UPDATE AVAILABLE OVERLAY ============ -->
|
||||||
|
<!-- Same pattern as the help overlay: instant open/close. -->
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
|
Background="{DynamicResource ScrimBrush}"
|
||||||
|
Tapped="OnUpdateAvailableOverlayBackdropTapped"
|
||||||
|
IsVisible="{Binding IsUpdateAvailableOpen}">
|
||||||
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
|
MaxWidth="440" Margin="16"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Margin="24" Spacing="16">
|
||||||
|
<TextBlock Text="{Binding Loc[update.title]}"
|
||||||
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
<Run Text="{Binding Loc[update.message]}"/>
|
||||||
|
<Run Text=" "/>
|
||||||
|
<Run Text="{Binding UpdateAvailableTag}" FontWeight="Bold"/>
|
||||||
|
</TextBlock>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{Binding Loc[update.download]}"
|
||||||
|
Classes="accent"
|
||||||
|
Click="OnOpenReleasePageClick"/>
|
||||||
|
<Button Content="{Binding Loc[update.dismiss]}"
|
||||||
|
Command="{Binding CloseUpdateAvailableCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ public partial class MainView : UserControl
|
|||||||
vm.IsHelpOpen = false;
|
vm.IsHelpOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnUpdateAvailableOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(e.Source, sender)) return;
|
||||||
|
if (DataContext is MainWindowViewModel vm)
|
||||||
|
vm.IsUpdateAvailableOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||||
@@ -137,6 +144,15 @@ public partial class MainView : UserControl
|
|||||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||||
|
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||||
|
if (launcher is not null)
|
||||||
|
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
|
||||||
|
vm.IsUpdateAvailableOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -149,6 +165,7 @@ public partial class MainView : UserControl
|
|||||||
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
||||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||||
|
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
|
||||||
}
|
}
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
/// <summary>A GitHub release newer than the running app.</summary>
|
||||||
|
public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the latest GitHub release for the project against the running app version.
|
||||||
|
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
|
||||||
|
/// so callers never need to distinguish "no update" from "couldn't check".
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateChecker
|
||||||
|
{
|
||||||
|
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||||
|
|
||||||
|
private sealed class GitHubReleaseResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")]
|
||||||
|
public string? TagName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("html_url")]
|
||||||
|
public string? HtmlUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||||
|
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
var release = await response.Content
|
||||||
|
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (release?.TagName is not { Length: > 0 } tag) return null;
|
||||||
|
|
||||||
|
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
|
||||||
|
return null;
|
||||||
|
if (remote <= local) return null;
|
||||||
|
|
||||||
|
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||||
|
private static bool TryParse(string raw, out Version version)
|
||||||
|
{
|
||||||
|
var s = raw.Trim();
|
||||||
|
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||||
|
var dash = s.IndexOf('-');
|
||||||
|
if (dash >= 0) s = s[..dash];
|
||||||
|
return Version.TryParse(s, out version!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user