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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Client/OwnCord.Client/Views/UpdateDialog.xaml.cs b/Client/OwnCord.Client/Views/UpdateDialog.xaml.cs
new file mode 100644
index 00000000..babe7b93
--- /dev/null
+++ b/Client/OwnCord.Client/Views/UpdateDialog.xaml.cs
@@ -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();
+ }
+}