From bae586907f0dde13426ebb3c38f109fd6a9174cd Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:04:24 +0100 Subject: [PATCH] feat: implement client auto-update with GitHub Release checking and update dialog --- Client/OwnCord.Client/App.xaml.cs | 21 ++ Client/OwnCord.Client/OwnCord.Client.csproj | 2 + .../OwnCord.Client/Services/IUpdateService.cs | 21 ++ .../OwnCord.Client/Services/UpdateService.cs | 256 ++++++++++++++++++ .../ViewModels/UpdateViewModel.cs | 90 ++++++ Client/OwnCord.Client/Views/UpdateDialog.xaml | 49 ++++ .../OwnCord.Client/Views/UpdateDialog.xaml.cs | 14 + 7 files changed, 453 insertions(+) create mode 100644 Client/OwnCord.Client/Services/IUpdateService.cs create mode 100644 Client/OwnCord.Client/Services/UpdateService.cs create mode 100644 Client/OwnCord.Client/ViewModels/UpdateViewModel.cs create mode 100644 Client/OwnCord.Client/Views/UpdateDialog.xaml create mode 100644 Client/OwnCord.Client/Views/UpdateDialog.xaml.cs diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs index c3653c15..b760b679 100644 --- a/Client/OwnCord.Client/App.xaml.cs +++ b/Client/OwnCord.Client/App.xaml.cs @@ -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(); + }); + } + }); } } diff --git a/Client/OwnCord.Client/OwnCord.Client.csproj b/Client/OwnCord.Client/OwnCord.Client.csproj index e3e33e3b..b0ad54eb 100644 --- a/Client/OwnCord.Client/OwnCord.Client.csproj +++ b/Client/OwnCord.Client/OwnCord.Client.csproj @@ -6,6 +6,8 @@ enable enable true + 0.1.0 + 0.1.0.0 diff --git a/Client/OwnCord.Client/Services/IUpdateService.cs b/Client/OwnCord.Client/Services/IUpdateService.cs new file mode 100644 index 00000000..f8e4b6d5 --- /dev/null +++ b/Client/OwnCord.Client/Services/IUpdateService.cs @@ -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 CheckForUpdateAsync(); + Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath); + void ApplyUpdate(string newExePath); + void CleanupOldVersion(); + void SkipVersion(string version); +} diff --git a/Client/OwnCord.Client/Services/UpdateService.cs b/Client/OwnCord.Client/Services/UpdateService.cs new file mode 100644 index 00000000..5e056f05 --- /dev/null +++ b/Client/OwnCord.Client/Services/UpdateService.cs @@ -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 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(); + 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(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 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? Assets { get; set; } +} + +internal class GitHubAsset +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("browser_download_url")] + public string BrowserDownloadUrl { get; set; } = ""; +} diff --git a/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs b/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs new file mode 100644 index 00000000..5a6288bb --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs @@ -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; +} diff --git a/Client/OwnCord.Client/Views/UpdateDialog.xaml b/Client/OwnCord.Client/Views/UpdateDialog.xaml new file mode 100644 index 00000000..b7e9efe1 --- /dev/null +++ b/Client/OwnCord.Client/Views/UpdateDialog.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +