From cb47b80a5a90785efbe44faea20b3b8651f0362a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:59:46 -0400 Subject: [PATCH] Velopack integration for Lite + data directory migration (#635) Data directory: - All data (config, DuckDB, archives, logs, alert state) now stored in %LOCALAPPDATA%\PerformanceMonitorLite\ instead of alongside exe - Fixed hardcoded AppContext.BaseDirectory in AlertStateService and ServerTab log viewer - Enables Velopack to replace app directory without losing data Velopack integration: - Custom Main() with VelopackApp.Build().Run() before WPF - About window: 3-step update flow with confirmation dialog - Startup check: 5-second delay, title bar notification - Falls back to browser-based check when no packages exist Pipeline: - Self-contained win-x64 publish for Lite - Separate channels: dashboard + lite (different package IDs) - vpk download/pack/upload for both apps Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 20 +++++--- Lite/App.xaml | 2 +- Lite/App.xaml.cs | 27 ++++++----- Lite/Controls/ServerTab.xaml.cs | 2 +- Lite/MainWindow.xaml.cs | 37 ++++++++++----- Lite/PerformanceMonitorLite.csproj | 2 + Lite/Program.cs | 26 ++++++++++ Lite/Services/AlertStateService.cs | 2 +- Lite/Windows/AboutWindow.xaml.cs | 76 +++++++++++++++++++++++++++++- 9 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 Lite/Program.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a8e8d96..92d06a69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,10 @@ jobs: - name: Publish Lite run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite + - name: Publish Lite (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack + - name: Publish CLI Installer run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release @@ -168,13 +172,16 @@ jobs: VERSION: ${{ steps.version.outputs.VERSION }} run: | dotnet tool install -g vpk - New-Item -ItemType Directory -Force -Path releases/velopack + New-Item -ItemType Directory -Force -Path releases/velopack-dashboard + New-Item -ItemType Directory -Force -Path releases/velopack-lite - # Download previous release for delta generation - vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN + # Dashboard: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard - # Pack Dashboard (use self-contained publish output) - vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack --channel win + # Lite: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite - name: Generate checksums if: github.event_name == 'release' @@ -202,4 +209,5 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.VERSION }} run: | - vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN diff --git a/Lite/App.xaml b/Lite/App.xaml index 63ae9596..b3e581bc 100644 --- a/Lite/App.xaml +++ b/Lite/App.xaml @@ -1,7 +1,7 @@ + > diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index 1631db19..3f70078f 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -166,18 +166,19 @@ protected override void OnStartup(StartupEventArgs e) base.OnStartup(e); - // Initialize paths - use executable directory for portability - var exeDirectory = AppDomain.CurrentDomain.BaseDirectory; - DataDirectory = exeDirectory; - ConfigDirectory = Path.Combine(exeDirectory, "config"); - DatabasePath = Path.Combine(exeDirectory, "monitor.duckdb"); - ArchiveDirectory = Path.Combine(exeDirectory, "archive"); + // Initialize paths — store data in %LOCALAPPDATA% so Velopack updates + // can replace the app directory without losing data + var appDataRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PerformanceMonitorLite"); + DataDirectory = appDataRoot; + ConfigDirectory = Path.Combine(appDataRoot, "config"); + DatabasePath = Path.Combine(appDataRoot, "monitor.duckdb"); + ArchiveDirectory = Path.Combine(appDataRoot, "archive"); // Ensure directories exist - if (!Directory.Exists(ConfigDirectory)) - { - Directory.CreateDirectory(ConfigDirectory); - } + Directory.CreateDirectory(ConfigDirectory); + Directory.CreateDirectory(Path.Combine(appDataRoot, "archive")); // Load settings LoadDefaultTimeRange(); @@ -187,7 +188,7 @@ protected override void OnStartup(StartupEventArgs e) Helpers.ThemeManager.Apply(ColorTheme); // Initialize logging - var logDirectory = Path.Combine(exeDirectory, "logs"); + var logDirectory = Path.Combine(appDataRoot, "logs"); AppLogger.Initialize(logDirectory); Helpers.MethodProfiler.Initialize(logDirectory); Helpers.QueryLogger.Initialize(logDirectory); @@ -198,6 +199,10 @@ protected override void OnStartup(StartupEventArgs e) AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; DispatcherUnhandledException += OnDispatcherUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + + // Create and show main window (StartupUri removed for Velopack custom Main) + var mainWindow = new MainWindow(); + mainWindow.Show(); } protected override void OnExit(ExitEventArgs e) diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 3f0ca82e..21bacfd2 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -4242,7 +4242,7 @@ private void UpdateCollectorDurationChart(List data) private void OpenLogFile_Click(object sender, RoutedEventArgs e) { - var logDir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs"); + var logDir = System.IO.Path.Combine(App.DataDirectory, "logs"); var logFile = System.IO.Path.Combine(logDir, $"lite_{DateTime.Now:yyyyMMdd}.log"); if (File.Exists(logFile)) diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 270fcd0a..3a8593a3 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -165,26 +165,41 @@ private async Task CheckForUpdatesOnStartupAsync() { try { + await Task.Delay(5000); // Don't slow down startup + if (!App.CheckForUpdatesOnStartup) return; - var result = await UpdateCheckService.CheckForUpdateAsync(); - if (result?.IsUpdateAvailable == true) + // Try Velopack first (supports download + apply) + try { - var answer = MessageBox.Show( - $"Performance Monitor {result.LatestVersion} is available (you have {result.CurrentVersion}).\n\nWould you like to open the download page?", - "Update Available", - MessageBoxButton.YesNo, - MessageBoxImage.Information); + var mgr = new Velopack.UpdateManager( + new Velopack.Sources.GithubSource( + "https://github.com/erikdarlingdata/PerformanceMonitor", null, false)); - if (answer == MessageBoxResult.Yes) + var newVersion = await mgr.CheckForUpdatesAsync(); + if (newVersion != null) { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + Dispatcher.Invoke(() => { - FileName = result.ReleaseUrl, - UseShellExecute = true + Title = $"Performance Monitor Lite — Update v{newVersion.TargetFullRelease.Version} available (Help > About)"; }); + return; } } + catch + { + // Velopack packages may not exist yet — fall through + } + + // Fallback: GitHub Releases API check + var result = await UpdateCheckService.CheckForUpdateAsync(); + if (result?.IsUpdateAvailable == true) + { + Dispatcher.Invoke(() => + { + Title = $"Performance Monitor Lite — Update {result.LatestVersion} available (Help > About)"; + }); + } } catch { diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 8f7a6f8e..35882b1b 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -4,6 +4,7 @@ net8.0-windows enable true + PerformanceMonitorLite.Program PerformanceMonitorLite PerformanceMonitorLite SQL Server Performance Monitor Lite @@ -63,6 +64,7 @@ + diff --git a/Lite/Program.cs b/Lite/Program.cs new file mode 100644 index 00000000..adcab3cc --- /dev/null +++ b/Lite/Program.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Velopack; + +namespace PerformanceMonitorLite +{ + public static class Program + { + [STAThread] + public static void Main(string[] args) + { + VelopackApp.Build().Run(); + + var app = new App(); + app.InitializeComponent(); + app.Run(); + } + } +} diff --git a/Lite/Services/AlertStateService.cs b/Lite/Services/AlertStateService.cs index f25830b0..f30fe86b 100644 --- a/Lite/Services/AlertStateService.cs +++ b/Lite/Services/AlertStateService.cs @@ -34,7 +34,7 @@ public class AlertStateService public AlertStateService() { - _stateFilePath = Path.Combine(AppContext.BaseDirectory, "alert_state.json"); + _stateFilePath = Path.Combine(App.DataDirectory, "alert_state.json"); _silencedServers = new HashSet(StringComparer.OrdinalIgnoreCase); _acknowledgedAlerts = new Dictionary(StringComparer.OrdinalIgnoreCase); Load(); diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs index 05a388a4..5395feeb 100644 --- a/Lite/Windows/AboutWindow.xaml.cs +++ b/Lite/Windows/AboutWindow.xaml.cs @@ -6,10 +6,12 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ +using System; using System.Diagnostics; using System.Reflection; using System.Windows; using PerformanceMonitorLite.Services; +using Velopack; namespace PerformanceMonitorLite.Windows; @@ -21,6 +23,9 @@ public partial class AboutWindow : Window private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; + private UpdateManager? _velopackMgr; + private Velopack.UpdateInfo? _pendingUpdate; + private bool _updateDownloaded; public AboutWindow() { @@ -45,6 +50,32 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) UpdateStatusText.Text = "Checking for updates..."; UpdateStatusText.Visibility = Visibility.Visible; + // Try Velopack first (supports download + apply) + try + { + _velopackMgr = new UpdateManager( + new Velopack.Sources.GithubSource("https://github.com/erikdarlingdata/PerformanceMonitor", null, false)); + + var updateInfo = await _velopackMgr.CheckForUpdatesAsync(); + if (updateInfo != null) + { + _pendingUpdate = updateInfo; + UpdateStatusText.Text = $"Update available: v{updateInfo.TargetFullRelease.Version} — click to install"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= UpdateStatusText_Click; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + return; + } + } + catch + { + // Velopack packages may not exist yet — fall through + } + + // Fallback: GitHub Releases API check (opens browser) var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true); if (result == null) @@ -54,8 +85,9 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) else if (result.IsUpdateAvailable) { _updateReleaseUrl = result.ReleaseUrl; - UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})"; + UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion}) — click to open releases"; UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click; UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush @@ -67,6 +99,48 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) } } + private async void VelopackDownload_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_velopackMgr == null || _pendingUpdate == null) return; + + // Step 3: restart with confirmation + if (_updateDownloaded) + { + var result = MessageBox.Show(this, + "The application will close and restart with the new version. Continue?", + "Update Ready", + MessageBoxButton.OKCancel, + MessageBoxImage.Question); + + if (result == MessageBoxResult.OK) + { + _velopackMgr.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease); + } + return; + } + + // Step 2: download + try + { + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; + UpdateStatusText.TextDecorations = null; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Arrow; + UpdateStatusText.Text = "Downloading update..."; + + await _velopackMgr.DownloadUpdatesAsync(_pendingUpdate); + + _updateDownloaded = true; + UpdateStatusText.Text = "Update downloaded."; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + } + catch (Exception ex) + { + UpdateStatusText.Text = $"Download failed: {ex.Message}"; + } + } + private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { if (!string.IsNullOrEmpty(_updateReleaseUrl))