mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: implement client auto-update with GitHub Release checking and update dialog
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user