diff --git a/Components/Modals/UpdateModalComponent.axaml b/Components/Modals/UpdateModalComponent.axaml new file mode 100644 index 0000000..d768a0c --- /dev/null +++ b/Components/Modals/UpdateModalComponent.axaml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Components/Modals/UpdateModalComponent.axaml.cs b/Components/Modals/UpdateModalComponent.axaml.cs new file mode 100644 index 0000000..fbfd25c --- /dev/null +++ b/Components/Modals/UpdateModalComponent.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Quell.ViewModels; + +namespace Quell.Components; + +public partial class UpdateModalComponent : UserControl +{ + public UpdateModalComponent() + { + InitializeComponent(); + } + + private void OnBackdropPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (DataContext is UpdateModalViewModel vm) + { + vm.DismissCommand.Execute(null); + } + } +} diff --git a/Components/Navigation/StatusBarComponent.axaml b/Components/Navigation/StatusBarComponent.axaml index 2f97597..0517c79 100644 --- a/Components/Navigation/StatusBarComponent.axaml +++ b/Components/Navigation/StatusBarComponent.axaml @@ -2,8 +2,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:vm="using:Quell.ViewModels" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="24" - x:Class="Quell.Components.StatusBarComponent"> + x:Class="Quell.Components.StatusBarComponent" + x:DataType="vm:MainViewModel"> - - + + + + + + +/// Model representing a GitHub release payload and version metadata. +/// +public class UpdateReleaseModel +{ + public string TagName { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string HtmlUrl { get; set; } = string.Empty; + public string PublishedAt { get; set; } = string.Empty; + public Version? ParsedVersion { get; set; } + public bool IsNewer { get; set; } +} diff --git a/Program.cs b/Program.cs index e5dbfb4..a3c39e3 100644 --- a/Program.cs +++ b/Program.cs @@ -1807,6 +1807,66 @@ await Quell.Services.DatabaseStorageService.Instance.UpdateConnectionAsync( // Restore defaults await Quell.Services.Terminal.TerminalSettingsService.Instance.SaveSettingsAsync(new Quell.Models.TerminalSettingsModel()); + // 23. Test Application Versioning Architecture, GitHub Releases Checker & Corner "Get Updates" Box + Console.WriteLine("\n[Action] Verifying Semantic Versioning, GitHub Releases Service & Corner 'Get Updates' Notification..."); + + // 23.1 Test Version Parsing & SemVer Comparisons + bool p1 = Quell.Services.AppUpdateService.TryParseVersion("v1.2.0", out var v1); + bool p2 = Quell.Services.AppUpdateService.TryParseVersion("1.0.0-rc1", out var v2); + bool p3 = Quell.Services.AppUpdateService.TryParseVersion("v0.9.8", out var v3); + if (!p1 || v1 == null || v1 != new Version(1, 2, 0)) throw new Exception("Version parsing for v1.2.0 failed!"); + if (!p2 || v2 == null || v2 != new Version(1, 0, 0)) throw new Exception("Version parsing for 1.0.0-rc1 failed!"); + if (!p3 || v3 == null || v3 >= Quell.Services.AppUpdateService.CurrentVersion) throw new Exception("SemVer comparison failed: v0.9.8 should be less than current version!"); + if (v1 <= Quell.Services.AppUpdateService.CurrentVersion) throw new Exception("SemVer comparison failed: v1.2.0 should be greater than current version!"); + Console.WriteLine($"[Success] Semantic version parsing & comparison verified (Current: {Quell.Services.AppUpdateService.CurrentVersionString})!"); + + // 23.2 Test Update Modal & Corner "Get Updates" Box Lifecycle + vm.UpdateModal.IsOpen = false; + vm.UpdateModal.IsCornerBadgeVisible = false; + + // Simulate newer release notification + Quell.Services.AppUpdateService.Instance.SimulateUpdateAvailable( + newVersion: "v1.1.0", + releaseTitle: "Quell Studio 1.1.0 - Next-Gen SQL Performance", + notes: "• Automatic GitHub Releases version detection & notification\n• GPU-Accelerated Fluid Modal Animations" + ); + vm.UpdateModal.ApplyRelease(Quell.Services.AppUpdateService.Instance.LatestRelease!); + vm.UpdateModal.Open(); + + if (!vm.UpdateModal.IsOpen || vm.UpdateModal.IsCornerBadgeVisible) + { + throw new Exception("UpdateModal should be Open, and CornerBadge should be hidden while modal is open!"); + } + if (vm.UpdateModal.LatestVersion != "v1.1.0" || !vm.UpdateModal.IsUpdateAvailable) + { + throw new Exception("UpdateModal version binding mismatch!"); + } + Console.WriteLine($"[Success] New update modal auto-opened: Version={vm.UpdateModal.LatestVersion}, Title='{vm.UpdateModal.ReleaseTitle}'!"); + + // Simulate user dismissing / clicking (X) on the modal + Console.WriteLine("[Action] User dismissed update modal with (X)..."); + vm.UpdateModal.Dismiss(); + + if (vm.UpdateModal.IsOpen || !vm.UpdateModal.IsCornerBadgeVisible) + { + throw new Exception("Upon modal dismissal, UpdateModal.IsOpen must be False and IsCornerBadgeVisible MUST be True!"); + } + Console.WriteLine("[Success] Modal closed; Corner 'Get Updates' box is now active and visible!"); + + // Simulate user clicking on corner "Get Updates" box anytime later + Console.WriteLine("[Action] User clicked on corner 'Get Updates' box..."); + vm.OpenUpdateModalCommand.Execute(null); + + if (!vm.UpdateModal.IsOpen || vm.UpdateModal.IsCornerBadgeVisible) + { + throw new Exception("Re-opening from corner box must show UpdateModal and hide the corner badge!"); + } + Console.WriteLine("[Success] Update modal successfully re-opened from corner 'Get Updates' box!"); + + // Reset state + vm.UpdateModal.Dismiss(); + Quell.Services.AppUpdateService.Instance.Reset(); + Console.WriteLine("\n>>> ALL TESTS (LAYOUT, TABLES, MODALS, TABS, SQL AUTOCOMPLETE, MULTI-DIALECT SQLITE, VECTOR ICONS, SPLASH SCREEN, STORAGE, DYNAMIC DATABASE PROVIDERS, ISOLATED STARTUP, HIERARCHICAL TREE VIEW, REAL QUERY, TARGET DATABASE ROUTING, DISCONNECT/DELETE, GRAPHICAL FOOTER CELL UPDATE, PRIMARY KEY NEUTRAL STYLING, MULTI-ROW BATCH DELETION, MULTI-COLUMN DROP, FOOTER REFRESH/FILTER, SIMULTANEOUS BATCH CELL EDITING, FOREIGN KEY NAVIGATION, HARDWARE GPU/CPU RENDERING PIPELINE & INTEGRATED TERMINAL) PASSED! <<<"); return; } diff --git a/Services/AppUpdateService.cs b/Services/AppUpdateService.cs new file mode 100644 index 0000000..39ad275 --- /dev/null +++ b/Services/AppUpdateService.cs @@ -0,0 +1,154 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using Quell.Models; + +namespace Quell.Services; + +/// +/// Service responsible for GitHub release versioning checks, semantic version comparisons, +/// and update notifications across Quell Studio. +/// +public class AppUpdateService +{ + private static AppUpdateService? _instance; + public static AppUpdateService Instance => _instance ??= new AppUpdateService(); + + public static readonly Version CurrentVersion = new(1, 0, 0); + public static string CurrentVersionString => $"v{CurrentVersion.Major}.{CurrentVersion.Minor}.{CurrentVersion.Build}"; + + public const string GitHubRepo = "cipherunits/Quell"; + public const string ReleasesApiUrl = $"https://api.github.com/repos/{GitHubRepo}/releases/latest"; + public const string DefaultReleasesPageUrl = $"https://github.com/{GitHubRepo}/releases"; + + private readonly HttpClient _httpClient; + + public bool IsUpdateAvailable { get; private set; } + public bool IsCornerBadgeVisible { get; set; } + public UpdateReleaseModel? LatestRelease { get; private set; } + + public event EventHandler? UpdateAvailable; + + public AppUpdateService(HttpClient? client = null) + { + _httpClient = client ?? new HttpClient + { + Timeout = TimeSpan.FromSeconds(4) + }; + if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) + { + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Quell-Studio", CurrentVersionString)); + } + } + + /// + /// Checks GitHub repository releases for a newer version than CurrentVersion. + /// Runs asynchronously and fails gracefully on network errors or timeouts. + /// + public async Task CheckForUpdatesAsync(bool isManualCheck = false) + { + try + { + using var response = await _httpClient.GetAsync(ReleasesApiUrl).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string tagName = root.TryGetProperty("tag_name", out var tagElem) ? tagElem.GetString() ?? "" : ""; + string name = root.TryGetProperty("name", out var nameElem) ? nameElem.GetString() ?? tagName : tagName; + string body = root.TryGetProperty("body", out var bodyElem) ? bodyElem.GetString() ?? "" : ""; + string htmlUrl = root.TryGetProperty("html_url", out var urlElem) ? urlElem.GetString() ?? "" : DefaultReleasesPageUrl; + string publishedAt = root.TryGetProperty("published_at", out var pubElem) ? pubElem.GetString() ?? "" : ""; + + if (TryParseVersion(tagName, out var remoteVer) && remoteVer != null) + { + bool isNewer = remoteVer > CurrentVersion; + var release = new UpdateReleaseModel + { + TagName = tagName, + Name = string.IsNullOrWhiteSpace(name) ? tagName : name, + Body = body, + HtmlUrl = htmlUrl, + PublishedAt = publishedAt, + ParsedVersion = remoteVer, + IsNewer = isNewer + }; + + if (isNewer) + { + LatestRelease = release; + IsUpdateAvailable = true; + UpdateAvailable?.Invoke(this, release); + } + + return release; + } + } + catch (Exception ex) + { + Console.WriteLine($"[AppUpdateService] CheckForUpdates error: {ex.Message}"); + } + + return null; + } + + /// + /// Parses version string from release tag name (e.g., 'v1.1.0' -> 1.1.0). + /// + public static bool TryParseVersion(string? tag, out Version? version) + { + version = null; + if (string.IsNullOrWhiteSpace(tag)) return false; + + string clean = tag.Trim().TrimStart('v', 'V'); + int dashIndex = clean.IndexOf('-'); + if (dashIndex > 0) clean = clean[..dashIndex]; + + // Ensure 3 segments for System.Version if only 1 or 2 are present + int dotCount = clean.Count(c => c == '.'); + if (dotCount == 0) clean += ".0.0"; + else if (dotCount == 1) clean += ".0"; + + return Version.TryParse(clean, out version); + } + + /// + /// Helper to trigger update availability (used for verification testing and offline preview). + /// + public void SimulateUpdateAvailable(string newVersion = "v1.1.0", string? releaseTitle = null, string? notes = null, string? releaseUrl = null) + { + TryParseVersion(newVersion, out var ver); + var release = new UpdateReleaseModel + { + TagName = newVersion, + Name = releaseTitle ?? $"Quell Studio {newVersion} - Performance & ER Engine Update", + Body = notes ?? "• Automatic GitHub Releases version detection & notification\n• Hardware GPU-accelerated fluid modal pop-in animations\n• Real-time startup sequence linked to actual sub-systems\n• Schema properties navigation & multi-dialect SQL autocomplete", + HtmlUrl = releaseUrl ?? DefaultReleasesPageUrl, + ParsedVersion = ver ?? new Version(1, 1, 0), + IsNewer = true + }; + + LatestRelease = release; + IsUpdateAvailable = true; + UpdateAvailable?.Invoke(this, release); + } + + /// + /// Resets update detection state (e.g. for testing). + /// + public void Reset() + { + IsUpdateAvailable = false; + IsCornerBadgeVisible = false; + LatestRelease = null; + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index d3dce2a..54e2005 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -42,6 +42,9 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] private SettingsModalViewModel _settingsModal; + [ObservableProperty] + private UpdateModalViewModel _updateModal; + [ObservableProperty] private QueryResultTableViewModel _sampleQueryResult; @@ -65,6 +68,7 @@ public MainViewModel() _exportModal = new ExportDataModalViewModel(); _diagramModal = new DatabaseDiagramModalViewModel(); _settingsModal = new SettingsModalViewModel(); + _updateModal = new UpdateModalViewModel(); _sampleQueryResult = new QueryResultTableViewModel(); _sampleSchema = new SchemaPropertiesTableViewModel(); @@ -218,6 +222,13 @@ private async Task InitializeStartupAsync() await Quell.Services.DatabaseStorageService.Instance.InitializeAsync(); await RefreshAvailableDatabasesAsync(); await Sidebar.RefreshAsync(); + + // Check for updates asynchronously from GitHub releases (non-blocking) + _ = Task.Run(async () => + { + await Task.Delay(2500).ConfigureAwait(false); + await AppUpdateService.Instance.CheckForUpdatesAsync().ConfigureAwait(false); + }); } private bool _isUpdatingTargetSilently; @@ -1466,6 +1477,12 @@ private List GetSampleDiagramTables() #endregion + [RelayCommand] + public void OpenUpdateModal() + { + UpdateModal.Open(); + } + [RelayCommand] public void Exit() { diff --git a/ViewModels/UpdateModalViewModel.cs b/ViewModels/UpdateModalViewModel.cs new file mode 100644 index 0000000..aba05e2 --- /dev/null +++ b/ViewModels/UpdateModalViewModel.cs @@ -0,0 +1,137 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Quell.Models; +using Quell.Services; + +namespace Quell.ViewModels; + +public partial class UpdateModalViewModel : ObservableObject +{ + [ObservableProperty] + private bool _isOpen; + + [ObservableProperty] + private bool _isCornerBadgeVisible; + + [ObservableProperty] + private string _currentVersion = AppUpdateService.CurrentVersionString; + + [ObservableProperty] + private string _latestVersion = "v1.0.0"; + + [ObservableProperty] + private string _releaseTitle = "New Quell Studio Update Available"; + + [ObservableProperty] + private string _releaseNotes = "A new release is ready on GitHub with performance improvements and new features."; + + [ObservableProperty] + private string _releaseUrl = AppUpdateService.DefaultReleasesPageUrl; + + [ObservableProperty] + private string _publishedAt = string.Empty; + + [ObservableProperty] + private bool _isUpdateAvailable; + + [ObservableProperty] + private bool _isChecking; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + public UpdateModalViewModel() + { + AppUpdateService.Instance.UpdateAvailable += OnUpdateAvailable; + } + + private void OnUpdateAvailable(object? sender, UpdateReleaseModel release) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + ApplyRelease(release); + Open(); + }); + } + + public void ApplyRelease(UpdateReleaseModel release) + { + LatestVersion = release.TagName; + ReleaseTitle = release.Name; + ReleaseNotes = string.IsNullOrWhiteSpace(release.Body) + ? "• General performance enhancements and stability fixes.\n• View full release notes on GitHub." + : release.Body; + ReleaseUrl = release.HtmlUrl; + PublishedAt = string.IsNullOrWhiteSpace(release.PublishedAt) ? "" : $"Published: {release.PublishedAt}"; + IsUpdateAvailable = release.IsNewer; + } + + [RelayCommand] + public void Open() + { + IsOpen = true; + IsCornerBadgeVisible = false; // Hide corner badge while the modal itself is open + } + + [RelayCommand] + public void Dismiss() + { + IsOpen = false; + // If an update is available, closing the modal surfaces the corner "Get Updates" badge + if (IsUpdateAvailable) + { + IsCornerBadgeVisible = true; + } + } + + [RelayCommand] + public void OpenReleaseUrl() + { + try + { + string url = string.IsNullOrWhiteSpace(ReleaseUrl) ? AppUpdateService.DefaultReleasesPageUrl : ReleaseUrl; + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch (Exception ex) + { + Console.WriteLine($"[UpdateModalViewModel] Error opening release URL: {ex.Message}"); + } + + Dismiss(); + } + + [RelayCommand] + public async Task CheckForUpdatesAsync() + { + IsChecking = true; + StatusMessage = "Checking GitHub for updates..."; + try + { + var release = await AppUpdateService.Instance.CheckForUpdatesAsync(isManualCheck: true).ConfigureAwait(false); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (release != null && release.IsNewer) + { + ApplyRelease(release); + StatusMessage = $"New version {release.TagName} is available!"; + Open(); + } + else + { + StatusMessage = "Quell Studio is up to date!"; + } + }); + } + finally + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => IsChecking = false); + } + } +} diff --git a/Views/MainView.axaml b/Views/MainView.axaml index b089e23..ce25a06 100644 --- a/Views/MainView.axaml +++ b/Views/MainView.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:Quell.ViewModels" xmlns:components="using:Quell.Components" + xmlns:ui="using:FluentAvalonia.UI.Controls" x:Class="Quell.Views.MainView" x:DataType="vm:MainViewModel" Background="{DynamicResource AppBackgroundBrush}"> @@ -66,11 +67,49 @@ + + + + + + diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index 8d9778a..3a2b0b0 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:Quell.ViewModels" xmlns:components="using:Quell.Components" + xmlns:ui="using:FluentAvalonia.UI.Controls" x:Class="Quell.Views.MainWindow" x:DataType="vm:MainViewModel" Icon="/Assets/quell_icon.ico" @@ -73,6 +74,43 @@ + + + + + @@ -80,5 +118,6 @@ +