feat: implement client auto-update with GitHub Release checking and update dialog

This commit is contained in:
jevb
2026-03-14 22:04:24 +01:00
parent 82a9985a6a
commit bae586907f
7 changed files with 453 additions and 0 deletions
+21
View File
@@ -1,7 +1,9 @@
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using OwnCord.Client.Services;
using OwnCord.Client.ViewModels;
using OwnCord.Client.Views;
namespace OwnCord.Client;
@@ -22,6 +24,25 @@ public partial class App : Application
var mainWindow = new MainWindow(connectVm, mainVm, credentialService, wsService);
mainWindow.Show();
// Clean up old binary from previous update
var updateService = new UpdateService();
updateService.CleanupOldVersion();
// Check for updates (non-blocking)
_ = Task.Run(async () =>
{
var info = await updateService.CheckForUpdateAsync();
if (info?.UpdateAvailable == true)
{
await Current.Dispatcher.InvokeAsync(() =>
{
var vm = new UpdateViewModel(updateService, info);
var dialog = new UpdateDialog(vm);
dialog.ShowDialog();
});
}
});
}
}
@@ -6,6 +6,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
</Project>
@@ -0,0 +1,21 @@
using System.Threading.Tasks;
namespace OwnCord.Client.Services;
public record UpdateInfo(
string CurrentVersion,
string LatestVersion,
string ReleaseNotes,
string DownloadUrl,
string ChecksumUrl,
bool UpdateAvailable
);
public interface IUpdateService
{
Task<UpdateInfo?> CheckForUpdateAsync();
Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath);
void ApplyUpdate(string newExePath);
void CleanupOldVersion();
void SkipVersion(string version);
}
@@ -0,0 +1,256 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Reflection;
using System.Linq;
using System.Collections.Generic;
namespace OwnCord.Client.Services;
public class UpdateService : IUpdateService
{
private const string GitHubApiUrl = "https://api.github.com/repos/J3vb/OwnCord/releases/latest";
private const string ValidUrlPrefix = "https://github.com/J3vb/OwnCord/releases/download/";
private static readonly string SettingsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OwnCord");
private static readonly string SettingsPath = Path.Combine(SettingsDir, "update-settings.json");
private readonly HttpClient _httpClient;
private UpdateSettings _settings;
public UpdateService() : this(new HttpClient()) { }
public UpdateService(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("OwnCord-Client/1.0");
_settings = LoadSettings();
}
public async Task<UpdateInfo?> CheckForUpdateAsync()
{
// Check 24-hour cache
if (_settings.LastCheckUtc.HasValue &&
DateTime.UtcNow - _settings.LastCheckUtc.Value < TimeSpan.FromHours(24))
{
return null;
}
try
{
var response = await _httpClient.GetAsync(GitHubApiUrl);
if (!response.IsSuccessStatusCode) return null;
var release = await response.Content.ReadFromJsonAsync<GitHubRelease>();
if (release == null) return null;
var currentVersion = GetCurrentVersion();
var latestVersion = release.TagName.TrimStart('v');
// Update cache timestamp
_settings.LastCheckUtc = DateTime.UtcNow;
SaveSettings();
var updateAvailable = CompareVersions(currentVersion, latestVersion) < 0;
// Check skip list
if (updateAvailable && _settings.SkippedVersions.Contains(latestVersion))
{
return null;
}
var downloadUrl = release.Assets?
.FirstOrDefault(a => a.Name == "OwnCord.Client.exe")?.BrowserDownloadUrl ?? "";
var checksumUrl = release.Assets?
.FirstOrDefault(a => a.Name == "checksums.sha256")?.BrowserDownloadUrl ?? "";
return new UpdateInfo(
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseNotes: release.Body ?? "",
DownloadUrl: downloadUrl,
ChecksumUrl: checksumUrl,
UpdateAvailable: updateAvailable
);
}
catch
{
return null;
}
}
public async Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath)
{
ValidateDownloadUrl(downloadUrl);
// Download checksum file
var checksumContent = await _httpClient.GetStringAsync(checksumUrl);
var expectedHash = ParseChecksumFile(checksumContent, Path.GetFileName(destPath));
// Download binary
using var response = await _httpClient.GetAsync(downloadUrl);
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(destPath);
await response.Content.CopyToAsync(fileStream);
fileStream.Close();
// Verify checksum
var actualHash = ComputeFileHash(destPath);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
File.Delete(destPath);
throw new InvalidOperationException(
$"Checksum mismatch: expected {expectedHash}, got {actualHash}");
}
}
public void ApplyUpdate(string newExePath)
{
var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName
?? throw new InvalidOperationException("Cannot determine current executable path");
var oldPath = currentExe + ".old";
// Remove stale .old if present
if (File.Exists(oldPath)) File.Delete(oldPath);
// Rename: current -> .old
File.Move(currentExe, oldPath);
// Move: new -> current
File.Move(newExePath, currentExe);
// Restart
Process.Start(new ProcessStartInfo
{
FileName = currentExe,
UseShellExecute = true
});
Environment.Exit(0);
}
public void CleanupOldVersion()
{
var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName;
if (currentExe == null) return;
var oldPath = currentExe + ".old";
if (File.Exists(oldPath))
{
try { File.Delete(oldPath); } catch { /* best effort */ }
}
}
public void SkipVersion(string version)
{
if (!_settings.SkippedVersions.Contains(version))
{
_settings.SkippedVersions.Add(version);
SaveSettings();
}
}
private static string GetCurrentVersion()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0";
}
private static int CompareVersions(string current, string latest)
{
if (Version.TryParse(current, out var v1) && Version.TryParse(latest, out var v2))
return v1.CompareTo(v2);
return string.Compare(current, latest, StringComparison.Ordinal);
}
private static void ValidateDownloadUrl(string url)
{
if (!url.StartsWith(ValidUrlPrefix, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Invalid download URL: {url}");
}
private static string ParseChecksumFile(string content, string filename)
{
foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[^1] == filename)
return parts[0];
}
throw new InvalidOperationException($"File '{filename}' not found in checksum data");
}
private static string ComputeFileHash(string filePath)
{
using var stream = File.OpenRead(filePath);
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private UpdateSettings LoadSettings()
{
try
{
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<UpdateSettings>(json) ?? new UpdateSettings();
}
}
catch { /* ignore corrupt settings */ }
return new UpdateSettings();
}
private void SaveSettings()
{
try
{
Directory.CreateDirectory(SettingsDir);
var json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(SettingsPath, json);
}
catch { /* best effort */ }
}
}
internal class UpdateSettings
{
[JsonPropertyName("last_check_utc")]
public DateTime? LastCheckUtc { get; set; }
[JsonPropertyName("skipped_versions")]
public List<string> SkippedVersions { get; set; } = new();
}
internal class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = "";
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = "";
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal class GitHubAsset
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = "";
}
@@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Input;
using OwnCord.Client.Services;
namespace OwnCord.Client.ViewModels;
public class UpdateViewModel : ViewModelBase
{
private readonly IUpdateService _updateService;
private readonly UpdateInfo _updateInfo;
private bool _isDownloading;
private string _statusText = "";
public string CurrentVersion => _updateInfo.CurrentVersion;
public string NewVersion => _updateInfo.LatestVersion;
public string ReleaseNotes => _updateInfo.ReleaseNotes;
public bool IsDownloading
{
get => _isDownloading;
private set { _isDownloading = value; OnPropertyChanged(); }
}
public string StatusText
{
get => _statusText;
private set { _statusText = value; OnPropertyChanged(); }
}
public ICommand UpdateNowCommand { get; }
public ICommand SkipVersionCommand { get; }
public ICommand RemindLaterCommand { get; }
// Result: true = update started, false = skipped, null = remind later
public bool? Result { get; private set; }
public UpdateViewModel(IUpdateService updateService, UpdateInfo updateInfo)
{
_updateService = updateService;
_updateInfo = updateInfo;
UpdateNowCommand = new RelayCommand(
() => _ = UpdateNowAsync(),
() => !IsDownloading);
SkipVersionCommand = new RelayCommand(
SkipVersion,
() => !IsDownloading);
RemindLaterCommand = new RelayCommand(RemindLater);
}
private async Task UpdateNowAsync()
{
IsDownloading = true;
StatusText = "Downloading update...";
try
{
var tempPath = Path.GetTempFileName();
await _updateService.DownloadAndVerifyAsync(
_updateInfo.DownloadUrl, _updateInfo.ChecksumUrl, tempPath);
StatusText = "Applying update...";
_updateService.ApplyUpdate(tempPath);
Result = true;
}
catch (Exception ex)
{
StatusText = $"Update failed: {ex.Message}";
IsDownloading = false;
}
}
private void SkipVersion()
{
_updateService.SkipVersion(_updateInfo.LatestVersion);
Result = false;
CloseRequested?.Invoke();
}
private void RemindLater()
{
Result = null;
CloseRequested?.Invoke();
}
public event Action? CloseRequested;
}
@@ -0,0 +1,49 @@
<Window x:Class="OwnCord.Client.Views.UpdateDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Update Available"
Width="500" Height="400"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" FontSize="18" FontWeight="Bold" Margin="0,0,0,10"
Text="A new version of OwnCord is available!"/>
<StackPanel Grid.Row="1" Margin="0,0,0,10">
<TextBlock>
<Run Text="Current version: "/>
<Run Text="{Binding CurrentVersion, Mode=OneWay}" FontWeight="SemiBold"/>
</TextBlock>
<TextBlock>
<Run Text="New version: "/>
<Run Text="{Binding NewVersion, Mode=OneWay}" FontWeight="SemiBold"/>
</TextBlock>
</StackPanel>
<GroupBox Grid.Row="2" Header="Release Notes" Margin="0,0,0,10">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock Text="{Binding ReleaseNotes}" TextWrapping="Wrap" Margin="5"/>
</ScrollViewer>
</GroupBox>
<TextBlock Grid.Row="3" Text="{Binding StatusText}" Margin="0,0,0,10"
Visibility="{Binding IsDownloading, Converter={StaticResource BoolToVisibility}}"/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Remind Me Later" Command="{Binding RemindLaterCommand}"
Margin="0,0,10,0" Padding="15,8"/>
<Button Content="Skip This Version" Command="{Binding SkipVersionCommand}"
Margin="0,0,10,0" Padding="15,8"/>
<Button Content="Update Now" Command="{Binding UpdateNowCommand}"
Padding="15,8" FontWeight="Bold"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,14 @@
using System.Windows;
using OwnCord.Client.ViewModels;
namespace OwnCord.Client.Views;
public partial class UpdateDialog : Window
{
public UpdateDialog(UpdateViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
viewModel.CloseRequested += () => Close();
}
}