From e532a2d74ea6bd8bf8f31ee006f81c1220a2acba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C4=8Cech?= Date: Tue, 6 Oct 2026 01:54:03 +0200 Subject: [PATCH 1/4] feat: check for updates from github and the microsoft store (#50) --- AGENTS.md | 13 +- FluentMath.Tests/SettingsServiceTests.cs | 24 ++ FluentMath/Distribution/AppDistribution.cs | 46 +++ FluentMath/Distribution/StoreUpdateSource.cs | 136 +++++++ FluentMath/Distribution/UpdateInstaller.cs | 171 +++++++++ FluentMath/Distribution/UpdateService.cs | 352 ++++++++++++++++++ FluentMath/MainWindow.xaml | 30 ++ FluentMath/MainWindow.xaml.cs | 32 ++ .../Persistence/Models/AppSettingsData.cs | 4 + .../Persistence/Services/SettingsService.cs | 32 ++ FluentMath/Views/SettingsPage.xaml | 78 +++- FluentMath/Views/SettingsPage.xaml.cs | 127 ++++++- FluentMath/Views/UpdateDialog.cs | 313 ++++++++++++++++ 13 files changed, 1342 insertions(+), 16 deletions(-) create mode 100644 FluentMath/Distribution/StoreUpdateSource.cs create mode 100644 FluentMath/Distribution/UpdateInstaller.cs create mode 100644 FluentMath/Distribution/UpdateService.cs create mode 100644 FluentMath/Views/UpdateDialog.cs diff --git a/AGENTS.md b/AGENTS.md index 0c10fb5..016eeca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,8 +70,9 @@ FluentMath/ ├── Assets/ the app icon and the package logos ├── Controls/ the formula display: CalculatorDisplay, MathPanel, XamlTextMeasurer; │ the converter layout every converter page shares: ConverterPanel -├── Distribution/ the channel the running build came from, installer, portable or store: -│ AppDistribution +├── Distribution/ the channel the running build came from, installer, portable or store, and the +│ update check for each: AppDistribution, UpdateService, UpdateInstaller, +│ StoreUpdateSource ├── Engines/ the input model and the evaluator, both UI-free: MathInputManager, MathEvaluator ├── Models/ the token model, the exact values, the calculator setup and the currency logic: │ MathToken, NavigationMetadata, EvaluationMetadata, ExactValue, ResultFormatter, @@ -89,7 +90,7 @@ FluentMath/ ├── Properties/ PublishProfiles, launchSettings ├── ViewModels/ CalculatorViewModel, ConverterViewModel, RelayCommand └── Views/ StandardPage, ScientificPage, CurrencyPage, VolumePage, LengthPage, SettingsPage, - PadEntrance, ICompactPage, SmallKeyLabels + UpdateDialog, PadEntrance, ICompactPage, SmallKeyLabels FluentMath.Tests/ the engine tests, plain net8.0, no reference to the app ``` @@ -238,7 +239,11 @@ repository setting, not in the build. `Persistence` folder beside it; the `.iss` excludes both, so an installed build never writes into Program Files. - **The release workflow creates a draft, and a human publishes it.** Publishing straight from CI would - put every tag in front of users the moment the build finishes. + put every tag in front of users the moment the build finishes, and the update check would offer it to + every running copy at once: `UpdateService` reads `releases/latest`, which only returns published releases. +- **The store build only names its update from GitHub.** Whether there is one is the Store's answer; the + GitHub release only gives it a version. Publish the GitHub release once the Store version is live, or the + store build shows an unnamed update until then. - **Two `.gitignore` negations stay.** The stock template silently excludes files the build needs: the macOS `Icon` rule swallowed `FluentMath/Assets/Icon/`, so a fresh checkout failed with `CS7064` and the error named the icon rather than anything about git, and `*.pubxml` swallowed all three publish diff --git a/FluentMath.Tests/SettingsServiceTests.cs b/FluentMath.Tests/SettingsServiceTests.cs index bc914fb..ead9824 100644 --- a/FluentMath.Tests/SettingsServiceTests.cs +++ b/FluentMath.Tests/SettingsServiceTests.cs @@ -109,5 +109,29 @@ public void AnUnknownThemeFallsBackToTheDefault(string? theme) Assert.Equal("Default", Service.AppTheme); } + + [Fact] + public void TheUpdateSettingsComeBackUnchanged() + { + Service.LoadFromData(new AppSettingsData { CheckUpdatesOnStartup = false, SkippedUpdateVersion = "2.5.0" }); + + Assert.False(Service.CheckUpdatesOnStartup); + Assert.Equal("2.5.0", Service.SkippedUpdateVersion); + + AppSettingsData back = Service.ToData(); + + Assert.False(back.CheckUpdatesOnStartup); + Assert.Equal("2.5.0", back.SkippedUpdateVersion); + } + + // a file written before the update check, or edited by hand, may carry a null + [Fact] + public void AMissingSkippedVersionMeansNone() + { + Service.LoadFromData(new AppSettingsData { SkippedUpdateVersion = null! }); + + Assert.Equal("", Service.SkippedUpdateVersion); + Assert.True(new AppSettingsData().CheckUpdatesOnStartup); + } } } diff --git a/FluentMath/Distribution/AppDistribution.cs b/FluentMath/Distribution/AppDistribution.cs index cba60e5..f54de79 100644 --- a/FluentMath/Distribution/AppDistribution.cs +++ b/FluentMath/Distribution/AppDistribution.cs @@ -1,12 +1,18 @@ using System; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Windows.ApplicationModel; +using Windows.System; namespace FluentMath.Distribution { // the distribution channel: // installer, portable or store, the one place to ask; installer and portable are the GitHub downloads and // differ only in where the state lives + // a packaged store build never runs its own updater: the store forbids it, the install folder is read only and + // signed, and the inno installer would leave a second copy public static class AppDistribution { // === win32 api imports === @@ -34,9 +40,49 @@ public static class AppDistribution public static bool IsPortableBuild => _isPortableBuild.Value; + // the in-app updater keys off this; (the store build updates through the store, see StoreUpdateSource) + public static bool SupportsSelfUpdate => !IsPackaged; + + // the product page, where an update can always be installed by hand; found by the package family name, + // so no store id has to be kept here + public static Task OpenStorePageAsync() + { + if (!IsPackaged) return Task.CompletedTask; + + string familyName; + try + { + familyName = Package.Current.Id.FamilyName; + } + catch + { + // no identity after all; nothing to open + return Task.CompletedTask; + } + + return LaunchStoreAsync(new Uri($"ms-windows-store://pdp/?PFN={familyName}")); + } + // === private helpers === + // Launcher first, the shell as the fallback; (the answer of Launcher may never come back while the store + // replaces the package, so nothing waits on it) + private static async Task LaunchStoreAsync(Uri uri) + { + try + { + if (await Launcher.LaunchUriAsync(uri)) return; + } + catch { /* the shell below gets the next try */ } + + try + { + Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true }); + } + catch { /* nothing left to try, the click simply does nothing */ } + } + // a zero length buffer probes the identity: ERROR_INSUFFICIENT_BUFFER when packaged, APPMODEL_ERROR_NO_PACKAGE // otherwise; (Package.Current would throw on every unpackaged start) private static bool DetectPackaged() diff --git a/FluentMath/Distribution/StoreUpdateSource.cs b/FluentMath/Distribution/StoreUpdateSource.cs new file mode 100644 index 0000000..b0a2c41 --- /dev/null +++ b/FluentMath/Distribution/StoreUpdateSource.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Windows.Services.Store; +using WinRT.Interop; + +namespace FluentMath.Distribution +{ + public enum StoreInstallPhase + { + Waiting, // queued, nothing moving yet + Downloading, + Installing // replacing the package ends this process + } + + public record StoreInstallProgress(StoreInstallPhase Phase, double Fraction); + + public enum StoreInstallResult + { + Installed, // rare, the install usually ends the process first + Canceled, + Failed // store error, low battery, metered connection, background installs off + } + + + // the store update source: + // asks the Microsoft Store for a newer package and installs it; the store never names the version, + // UpdateService takes that from GitHub + public static class StoreUpdateSource + { + // === win32 api imports === + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int RegisterApplicationRestart(string? commandLine, uint flags); + + [DllImport("kernel32.dll")] + private static extern int UnregisterApplicationRestart(); + + + // === fields === + + // a restart after an update only, not after a crash, a hang or a reboot + private const uint RestartNoCrash = 0x1; + private const uint RestartNoHang = 0x2; + private const uint RestartNoReboot = 0x8; + + private static StoreContext? _context; + private static IReadOnlyList? _updates; + + + // === public api === + + // throws on an unreachable store (a failed check); ownerWindow carries the store context, see GetContext + public static async Task HasUpdateAsync(nint ownerWindow) + { + var context = GetContext(ownerWindow); + + _updates = await context.GetAppAndOptionalStorePackageUpdatesAsync(); + return _updates.Count > 0; + } + + // a silent install; our own update dialog is the consent, as in the GitHub builds + // the store ends the process to replace the package, so the app registers for a restart first; installs what + // the last HasUpdateAsync found, on its context + public static async Task InstallAsync(IProgress progress, CancellationToken ct) + { + var context = _context; + if (context == null || _updates == null || _updates.Count == 0) return StoreInstallResult.Failed; + if (!context.CanSilentlyDownloadStorePackageUpdates) return StoreInstallResult.Failed; + + RegisterApplicationRestart(null, RestartNoCrash | RestartNoHang | RestartNoReboot); + + var reporter = new Progress(status => + { + var phase = ToPhase(status.PackageUpdateState); + if (phase.HasValue) progress.Report(new StoreInstallProgress(phase.Value, status.PackageDownloadProgress)); + }); + + try + { + var result = await context.TrySilentDownloadAndInstallStorePackageUpdatesAsync(_updates).AsTask(ct, reporter); + + switch (result.OverallState) + { + case StorePackageUpdateState.Completed: + return StoreInstallResult.Installed; + + case StorePackageUpdateState.Canceled: + UnregisterApplicationRestart(); + return StoreInstallResult.Canceled; + + default: + UnregisterApplicationRestart(); + return StoreInstallResult.Failed; + } + } + catch (OperationCanceledException) + { + UnregisterApplicationRestart(); + return StoreInstallResult.Canceled; + } + catch + { + UnregisterApplicationRestart(); + throw; + } + } + + + // === private helpers === + + // without a CoreWindow the store context is tied to the main window + private static StoreContext GetContext(nint ownerWindow) + { + if (_context != null) return _context; + + var context = StoreContext.GetDefault(); + InitializeWithWindow.Initialize(context, ownerWindow); + + _context = context; + return context; + } + + // no error states, the install result reports those + private static StoreInstallPhase? ToPhase(StorePackageUpdateState state) => state switch + { + StorePackageUpdateState.Pending => StoreInstallPhase.Waiting, + StorePackageUpdateState.Downloading => StoreInstallPhase.Downloading, + StorePackageUpdateState.Deploying => StoreInstallPhase.Installing, + StorePackageUpdateState.Completed => StoreInstallPhase.Installing, + _ => null + }; + } +} diff --git a/FluentMath/Distribution/UpdateInstaller.cs b/FluentMath/Distribution/UpdateInstaller.cs new file mode 100644 index 0000000..0460b4a --- /dev/null +++ b/FluentMath/Distribution/UpdateInstaller.cs @@ -0,0 +1,171 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace FluentMath.Distribution +{ + // the update installer: + // downloads the asset of this build and hands the replacement to a detached powershell, since a running + // process cannot replace itself + // the two GitHub channels differ: the portable zip over an installed build would leave a stale uninstall entry and + // move the settings into program files (portable.txt) + public static class UpdateInstaller + { + // === fields === + + private const string InstallerAssetName = "FluentMath_Installer.exe"; + private const string PortableAssetPrefix = "FluentMath_Portable_"; + private const string DownloadFolderName = "FluentMath_Update"; + + // apart from the check client; a portable zip of about 90 MB outlasts the ten second api timeout + private static readonly HttpClient _http = CreateClient(); + + + // === public api === + + public static string PickAssetName(string version) => + AppDistribution.IsPortableBuild ? $"{PortableAssetPrefix}{version}.zip" : InstallerAssetName; + + public static string DownloadFolder => + Path.Combine(Path.GetTempPath(), DownloadFolderName); + + // streams the asset to disk with progress 0 to 1; null without a matching asset (the + // caller opens the release page) + public static async Task DownloadAsync(UpdateInfo? info, IProgress? progress, CancellationToken ct) + { + if (info == null || string.IsNullOrEmpty(info.AssetUrl)) return null; + + Directory.CreateDirectory(DownloadFolder); + string targetPath = Path.Combine(DownloadFolder, info.AssetName); + + using var response = await _http.GetAsync(info.AssetUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + long total = response.Content.Headers.ContentLength ?? info.AssetSize; + long received = 0; + + try + { + using (var source = await response.Content.ReadAsStreamAsync(ct)) + using (var target = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true)) + { + byte[] buffer = new byte[81920]; + int read; + + while ((read = await source.ReadAsync(buffer, ct)) > 0) + { + await target.WriteAsync(buffer.AsMemory(0, read), ct); + received += read; + + if (total > 0) progress?.Report((double)received / total); + } + } + } + catch + { + // or a partial file of up to 90 MB sits in %TEMP% + TryDeletePartial(targetPath); + throw; + } + + return targetPath; + } + + // starts the handoff script; the caller exits right after, which the script waits for + public static void ApplyAndRestart(string downloadedPath) + { + string? exePath = Environment.ProcessPath; + string? appFolder = Path.GetDirectoryName(exePath); + if (string.IsNullOrEmpty(exePath) || string.IsNullOrEmpty(appFolder)) return; + + bool portable = AppDistribution.IsPortableBuild; + string script = portable + ? BuildPortableScript(downloadedPath, appFolder, exePath) + : BuildInstallerScript(downloadedPath, exePath); + + var psi = new ProcessStartInfo + { + FileName = "powershell", + UseShellExecute = false, + + // no console; the installer shows the inno progress, the portable path is silent (why its + // extraction avoids the slow cmdlet) + CreateNoWindow = true, + WorkingDirectory = DownloadFolder + }; + + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-ExecutionPolicy"); + psi.ArgumentList.Add("Bypass"); + psi.ArgumentList.Add("-Command"); + psi.ArgumentList.Add(script); + + Process.Start(psi); + } + + + // === private helpers === + + private static HttpClient CreateClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; + client.DefaultRequestHeaders.Add("User-Agent", "FluentMath"); + + return client; + } + + // the zip has one top level folder (the workflow compresses the staging folder), so the copy comes out of it + // ExtractToDirectory, not Expand-Archive, which is slow over five hundred files with nothing on screen + // paths single quoted with quotes doubled; the relaunch sits in finally, so a half-failed + // copy still leaves a running app + private static string BuildPortableScript(string zipPath, string appFolder, string exePath) + { + string extractPath = Path.Combine(DownloadFolder, "extract"); + + return "$ErrorActionPreference='Stop'; " + + "try { " + + "Wait-Process -Name 'FluentMath' -ErrorAction SilentlyContinue; " + + $"if (Test-Path -LiteralPath {Quote(extractPath)}) {{ Remove-Item -LiteralPath {Quote(extractPath)} -Recurse -Force }}; " + + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + $"[System.IO.Compression.ZipFile]::ExtractToDirectory({Quote(zipPath)}, {Quote(extractPath)}); " + + $"$inner = Get-ChildItem -LiteralPath {Quote(extractPath)} -Directory | Select-Object -First 1; " + + $"$source = if ($inner) {{ $inner.FullName }} else {{ {Quote(extractPath)} }}; " + + $"Copy-Item -Path (Join-Path $source '*') -Destination {Quote(appFolder)} -Recurse -Force; " + + $"Remove-Item -LiteralPath {Quote(extractPath)} -Recurse -Force; " + + $"Remove-Item -LiteralPath {Quote(zipPath)} -Force " + + "} " + + $"catch {{ Start-Process explorer.exe {Quote(DownloadFolder)} }} " + + $"finally {{ Start-Process -FilePath {Quote(exePath)} }}"; + } + + // /SILENT, not /VERYSILENT, so the inno progress shows (an invisible minute looks like a crash); the .iss + // launch is skipifsilent, so the relaunch happens here + // the app runs unelevated, so setup asks for elevation itself (Program Files); a declined prompt still + // relaunches the old build + private static string BuildInstallerScript(string installerPath, string exePath) + { + return "$ErrorActionPreference='Stop'; " + + "try { " + + "Wait-Process -Name 'FluentMath' -ErrorAction SilentlyContinue; " + + $"Start-Process -FilePath {Quote(installerPath)} -ArgumentList '/SILENT','/SUPPRESSMSGBOXES','/NORESTART','/CLOSEAPPLICATIONS' -Wait; " + + $"Remove-Item -LiteralPath {Quote(installerPath)} -Force -ErrorAction SilentlyContinue " + + "} " + + $"catch {{ Start-Process explorer.exe {Quote(DownloadFolder)} }} " + + $"finally {{ Start-Process -FilePath {Quote(exePath)} }}"; + } + + private static void TryDeletePartial(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch { /* still locked or already gone; the next attempt truncates it anyway */ } + } + + private static string Quote(string value) => "'" + value.Replace("'", "''") + "'"; + } +} diff --git a/FluentMath/Distribution/UpdateService.cs b/FluentMath/Distribution/UpdateService.cs new file mode 100644 index 0000000..c2831c3 --- /dev/null +++ b/FluentMath/Distribution/UpdateService.cs @@ -0,0 +1,352 @@ +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using FluentMath.Persistence.Services; +using Microsoft.UI.Dispatching; + +namespace FluentMath.Distribution +{ + // a release: the bare version, its page, and the asset matching this distribution channel + public record UpdateInfo( + string Version, // three part, no v, e.g. "2.5.0" + string ReleaseUrl, + string Notes, // the raw markdown body + string AssetName, + string AssetUrl, + long AssetSize + ); + + + public enum UpdateCheckResult + { + UpToDate, + UpdateAvailable, + Failed // no network, rate limited, malformed response; the reason is never shown + } + + + // what the settings update card shows; (wider than UpdateCheckResult, it covers the states between calls) + public enum UpdateUiState + { + Unknown, // nothing asked yet (startup check off, or not run yet) + Checking, + UpToDate, + UpdateAvailable, + Skipped, // a newer release the user skipped + Failed + } + + + // the update service: + // asks the GitHub releases api once per app start for a newer version, for the title bar and the settings; the + // api returns published releases only, so a release workflow draft never reaches a user + // the store build asks the Microsoft Store instead (see StoreUpdateSource); GitHub only names the version there, + // since a GitHub release can go public before the store version clears certification + public class UpdateService + { + // === fields === + + private const string LatestReleaseUrl = "https://api.github.com/repos/cechout/fluent-math/releases/latest"; + private const string ReleasesPageUrl = "https://github.com/cechout/fluent-math/releases"; + + // one client per process; a per-call using would leave a socket in TIME_WAIT per check + private static readonly HttpClient _http = CreateClient(); + + // GitHub allows 60 unauthenticated requests per hour and ip, so a manual check inside this window gets the + // last result; (a failed check leaves LastCheckedAt alone, a retry is never held back) + private static readonly TimeSpan CheckCooldown = TimeSpan.FromSeconds(60); + + // shared with the release history, so the headers GitHub requires exist once + internal static HttpClient Http => _http; + + private DispatcherQueue? _dispatcherQueue; + private bool _hasCheckedOnStartup; + + // the main window, which the store context is tied to + private nint _ownerWindow; + + // the last answer, newer or not + private UpdateInfo? _latestRelease; + private bool _isNewer; + + + // === singleton instance === + + private static readonly UpdateService _instance = new UpdateService(); + public static UpdateService Instance => _instance; + + private UpdateService() { } + + + // === public api === + + // on the UI thread whenever the state moves (found, skipped, checking); one event keeps title bar and + // settings in step + public event Action? UpdateStateChanged; + + public UpdateUiState UiState { get; private set; } = UpdateUiState.Unknown; + + // the last check that got an answer; a failed one leaves it alone + public DateTimeOffset? LastCheckedAt { get; private set; } + + public bool IsUpdateAvailable => UiState == UpdateUiState.UpdateAvailable; + + // the release to install; null unless newer and not skipped, which keeps pill and dialog off a declined version + public UpdateInfo? Latest => _isNewer && UiState != UpdateUiState.Skipped ? _latestRelease : null; + + // the newest release, newer or not + public UpdateInfo? LatestRelease => _latestRelease; + + public static string CurrentVersion => FormatVersion(Assembly.GetExecutingAssembly().GetName().Version); + + // every version in front of the user, with the v (v2.4.0); internally the bare string stays (asset names, the + // skipped version, comparisons) + public static string VersionLabel(string? version) => + string.IsNullOrWhiteSpace(version) ? "" : $"v{version.TrimStart('v', 'V')}"; + + // the one automatic check, once the window is up; the guards live here, so the policy on reaching out sits in + // one place (the window is kept even with the check off, a check by hand still needs it) + public void Start(nint ownerWindow) + { + _ownerWindow = ownerWindow; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + if (!SettingsService.Instance.CheckUpdatesOnStartup) return; + if (_hasCheckedOnStartup) return; + + _hasCheckedOnStartup = true; + + _ = CheckAsync(); + } + + // ignoreSkippedVersion, from the settings card when the user asks for a skipped version + public async Task CheckAsync(bool ignoreSkippedVersion = false) + { + _dispatcherQueue ??= DispatcherQueue.GetForCurrentThread(); + + // see CheckCooldown; the state stays as the last answer left it + if (LastCheckedAt.HasValue && DateTimeOffset.Now - LastCheckedAt.Value < CheckCooldown) + { + return UiState == UpdateUiState.UpdateAvailable + ? UpdateCheckResult.UpdateAvailable + : UpdateCheckResult.UpToDate; + } + + UiState = UpdateUiState.Checking; + RaiseStateChanged(); + + try + { + var (parsed, isNewer) = AppDistribution.SupportsSelfUpdate + ? await FetchLatestReleaseAsync() + : await AskStoreAsync(); + + _latestRelease = Simulate(parsed, ref isNewer); + _isNewer = isNewer; + LastCheckedAt = DateTimeOffset.Now; + + if (_latestRelease == null || !isNewer) + { + UiState = UpdateUiState.UpToDate; + RaiseStateChanged(); + return UpdateCheckResult.UpToDate; + } + + // a skipped version stays out of pill and dialog; the release is kept for the way back + bool skipped = !ignoreSkippedVersion && IsSkipped(_latestRelease.Version); + if (skipped) + { + UiState = UpdateUiState.Skipped; + RaiseStateChanged(); + return UpdateCheckResult.UpToDate; + } + + UiState = UpdateUiState.UpdateAvailable; + RaiseStateChanged(); + + return UpdateCheckResult.UpdateAvailable; + } + catch (Exception ex) + { + Debug.WriteLine($"[UpdateService] check failed: {ex.Message}"); + + UiState = UpdateUiState.Failed; + RaiseStateChanged(); + + return UpdateCheckResult.Failed; + } + } + + // persisted across restarts, as "Skip this version" promises; safe because the settings card names the + // skipped version and clears it + public void SkipVersion() + { + if (_latestRelease == null || !_isNewer) return; + if (string.IsNullOrEmpty(_latestRelease.Version)) return; // a store update without a GitHub name + + SettingsService.Instance.SkippedUpdateVersion = _latestRelease.Version; + + UiState = UpdateUiState.Skipped; + RaiseStateChanged(); + } + + // the way back the settings card offers + public void ClearSkippedVersion() + { + SettingsService.Instance.SkippedUpdateVersion = ""; + + if (_isNewer && _latestRelease != null) UiState = UpdateUiState.UpdateAvailable; + RaiseStateChanged(); + } + + public string SkippedVersion => SettingsService.Instance.SkippedUpdateVersion; + + + // === private helpers === + + private static HttpClient CreateClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + // GitHub rejects a request without a user agent + client.DefaultRequestHeaders.Add("User-Agent", "FluentMath"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); + + return client; + } + + private static async Task<(UpdateInfo? Release, bool IsNewer)> FetchLatestReleaseAsync() + { + string json = await _http.GetStringAsync(LatestReleaseUrl); + + var release = ParseRelease(json, out bool isNewer); + return (release, isNewer); + } + + // the store build: the store decides, the latest GitHub release only names it, and only while newer + // than this build (otherwise the update has no version); with nothing on offer a GitHub release + // ahead of the store is dropped + private async Task<(UpdateInfo? Release, bool IsNewer)> AskStoreAsync() + { + var storeCheck = StoreUpdateSource.HasUpdateAsync(_ownerWindow); + + UpdateInfo? release = null; + bool isNewer = false; + + try + { + (release, isNewer) = await FetchLatestReleaseAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"[UpdateService] release lookup failed: {ex.Message}"); + } + + // an unreachable store fails the check, so this one throws + bool hasUpdate = await storeCheck; + + if (!hasUpdate) return (isNewer ? null : release, false); + + return isNewer && release != null + ? (release, true) + : (new UpdateInfo("", ReleasesPageUrl, "", "", "", 0), true); + } + + // debug only: a simulatedVersion above the running one makes every check report that release, with the real + // notes, url and asset; compiled out of release + private static UpdateInfo? Simulate(UpdateInfo? release, ref bool isNewer) + { +#if DEBUG + const string simulatedVersion = ""; // e.g. "9.9.9"; empty = off + + if (simulatedVersion.Length > 0 && release != null) + { + Debug.WriteLine($"[UpdateService] simulating an available update: {simulatedVersion}"); + + isNewer = true; + return release with { Version = simulatedVersion }; + } +#endif + + return release; + } + + // whatever the api described, installable or not (isNewer says that); null only for an unusable response + private static UpdateInfo? ParseRelease(string json, out bool isNewer) + { + isNewer = false; + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + string tag = root.GetProperty("tag_name").GetString() ?? ""; + string version = tag.TrimStart('v', 'V'); + if (!Version.TryParse(version, out var releaseVersion)) return null; + + var currentVersion = Assembly.GetExecutingAssembly().GetName().Version; + if (currentVersion == null) return null; + + // both cut to three parts; the tag parses with Revision -1, the assembly has a fourth part, and -1 sorts + // below 0 + isNewer = Normalize(releaseVersion) > Normalize(currentVersion); + + string releaseUrl = root.TryGetProperty("html_url", out var htmlUrl) + ? htmlUrl.GetString() ?? ReleasesPageUrl + : ReleasesPageUrl; + + string notes = root.TryGetProperty("body", out var bodyElement) + ? bodyElement.GetString() ?? "" + : ""; + + string formattedVersion = FormatVersion(releaseVersion); + string wantedAsset = UpdateInstaller.PickAssetName(formattedVersion); + + if (root.TryGetProperty("assets", out var assets)) + { + foreach (var asset in assets.EnumerateArray()) + { + string name = asset.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : ""; + if (!string.Equals(name, wantedAsset, StringComparison.OrdinalIgnoreCase)) continue; + + string url = asset.TryGetProperty("browser_download_url", out var urlElement) + ? urlElement.GetString() ?? "" + : ""; + if (string.IsNullOrEmpty(url)) continue; + + long size = asset.TryGetProperty("size", out var sizeElement) ? sizeElement.GetInt64() : 0; + + return new UpdateInfo(formattedVersion, releaseUrl, notes, name, url, size); + } + } + + // a release without the matching asset still counts; the dialog opens the release page instead + return new UpdateInfo(formattedVersion, releaseUrl, notes, "", "", 0); + } + + private static bool IsSkipped(string version) => + !string.IsNullOrEmpty(version) + && string.Equals(SettingsService.Instance.SkippedUpdateVersion, version, StringComparison.OrdinalIgnoreCase); + + private static Version Normalize(Version version) => + new Version(version.Major, version.Minor, Math.Max(version.Build, 0)); + + private static string FormatVersion(Version? version) => + version == null ? "" : $"{version.Major}.{version.Minor}.{Math.Max(version.Build, 0)}"; + + // the check may resume off the UI thread; marshalled once here, consumers do not dispatch again + private void RaiseStateChanged() + { + var queue = _dispatcherQueue; + if (queue == null || queue.HasThreadAccess) + { + UpdateStateChanged?.Invoke(); + return; + } + + queue.TryEnqueue(() => UpdateStateChanged?.Invoke()); + } + } +} diff --git a/FluentMath/MainWindow.xaml b/FluentMath/MainWindow.xaml index ea31020..3b5606a 100644 --- a/FluentMath/MainWindow.xaml +++ b/FluentMath/MainWindow.xaml @@ -54,6 +54,36 @@ + + + + + + diff --git a/FluentMath/MainWindow.xaml.cs b/FluentMath/MainWindow.xaml.cs index a49d64c..e919e09 100644 --- a/FluentMath/MainWindow.xaml.cs +++ b/FluentMath/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using FluentMath.Distribution; using FluentMath.Persistence.Models; using FluentMath.Persistence.Services; using FluentMath.Views; @@ -98,6 +99,7 @@ public MainWindow() _titleBarIcon = AppTitleBar.IconSource; _titleBarTitle = AppTitleBar.Title; AppTitleBar.LeftHeader = null; + AppTitleBar.Content = null; // the update pill, see ShowUpdatePill ApplyTheme(SettingsService.Instance.AppTheme); SettingsService.Instance.ThemeChanged += ApplyTheme; @@ -110,6 +112,10 @@ public MainWindow() AppWindow.Changed += AppWindow_Changed; this.Closed += MainWindow_Closed; + + // last; the check runs in the background and only ever adds the pill + UpdateService.Instance.UpdateStateChanged += ShowUpdatePill; + UpdateService.Instance.Start(this.GetWindowHandle()); } @@ -249,6 +255,7 @@ private void ShowCompactChrome(bool compact) AppTitleBar.IconSource = compact ? null : _titleBarIcon; AppTitleBar.Title = compact ? "" : _titleBarTitle; AppTitleBar.LeftHeader = compact ? CompactReturnButton : null; + ShowUpdatePill(); if (MainFrame.Content is ICompactPage page) { @@ -271,6 +278,31 @@ private void SaveCompactSize() } + // === update === + + // the pill names the waiting version; a store update GitHub could not name yet still needs a label + private void ShowUpdatePill() + { + UpdateService service = UpdateService.Instance; + + string versionLabel = UpdateService.VersionLabel(service.Latest?.Version); + UpdatePillText.Text = versionLabel.Length > 0 ? versionLabel : "Update"; + + AppTitleBar.Content = service.IsUpdateAvailable && !_isCompact ? UpdatePillButton : null; + } + + private async void UpdatePillButton_Click(object sender, RoutedEventArgs e) + { + await UpdateDialog.ShowAsync(Content.XamlRoot, UpdateService.Instance.Latest); + } + + // the update script waits for this process to end; Closed flushes what still waits to be saved + public void ExitForUpdate() + { + this.Close(); + } + + // === window state === // the saved bounds when they still overlap a monitor, else the start size; a saved maximize either way diff --git a/FluentMath/Persistence/Models/AppSettingsData.cs b/FluentMath/Persistence/Models/AppSettingsData.cs index c6bdeaa..b381bbe 100644 --- a/FluentMath/Persistence/Models/AppSettingsData.cs +++ b/FluentMath/Persistence/Models/AppSettingsData.cs @@ -28,6 +28,10 @@ public class AppSettingsData public string AppTheme { get; set; } = "Default"; // Default, Light or Dark public StartupPage StartupPage { get; set; } = StartupPage.Standard; + // --- updates --- + public bool CheckUpdatesOnStartup { get; set; } = true; + public string SkippedUpdateVersion { get; set; } = ""; // e.g. "2.5.0", empty = none + // --- calculator --- // see CalculatorSettings; (the number format is kept flat) public AngleMode AngleMode { get; set; } = CalculatorDefaults.AngleMode; diff --git a/FluentMath/Persistence/Services/SettingsService.cs b/FluentMath/Persistence/Services/SettingsService.cs index 63ea94a..5ee3b14 100644 --- a/FluentMath/Persistence/Services/SettingsService.cs +++ b/FluentMath/Persistence/Services/SettingsService.cs @@ -69,6 +69,34 @@ public StartupPage StartupPage } } + // the one automatic GitHub or store request per start; UpdateService asks when it starts + private bool _checkUpdatesOnStartup = Defaults.CheckUpdatesOnStartup; + public bool CheckUpdatesOnStartup + { + get => _checkUpdatesOnStartup; + set + { + if (_checkUpdatesOnStartup == value) return; + + _checkUpdatesOnStartup = value; + SaveDebounced(); + } + } + + // the version "Skip this version" declined, bare (2.5.0); empty = none + private string _skippedUpdateVersion = Defaults.SkippedUpdateVersion; + public string SkippedUpdateVersion + { + get => _skippedUpdateVersion; + set + { + if (_skippedUpdateVersion == value) return; + + _skippedUpdateVersion = value; + SaveDebounced(); + } + } + // === persistence === @@ -80,6 +108,8 @@ public void LoadFromData(AppSettingsData data) _appTheme = data.AppTheme is "Default" or "Light" or "Dark" ? data.AppTheme : Defaults.AppTheme; _startupPage = Defined(data.StartupPage, Defaults.StartupPage); + _checkUpdatesOnStartup = data.CheckUpdatesOnStartup; + _skippedUpdateVersion = data.SkippedUpdateVersion ?? Defaults.SkippedUpdateVersion; Calculator.AngleMode = Defined(data.AngleMode, Defaults.AngleMode); Calculator.ExactFirst = data.ExactFirst; @@ -106,6 +136,8 @@ public AppSettingsData ToData() { AppTheme = _appTheme, StartupPage = _startupPage, + CheckUpdatesOnStartup = _checkUpdatesOnStartup, + SkippedUpdateVersion = _skippedUpdateVersion, AngleMode = Calculator.AngleMode, ExactFirst = Calculator.ExactFirst, diff --git a/FluentMath/Views/SettingsPage.xaml b/FluentMath/Views/SettingsPage.xaml index 69e4649..d9fde4f 100644 --- a/FluentMath/Views/SettingsPage.xaml +++ b/FluentMath/Views/SettingsPage.xaml @@ -301,7 +301,7 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/FluentMath/Views/SettingsPage.xaml.cs b/FluentMath/Views/SettingsPage.xaml.cs index ce2d193..3bb9543 100644 --- a/FluentMath/Views/SettingsPage.xaml.cs +++ b/FluentMath/Views/SettingsPage.xaml.cs @@ -1,4 +1,5 @@ using CommunityToolkit.WinUI.Controls; +using FluentMath.Distribution; using FluentMath.Models; using FluentMath.Models.Converters; using FluentMath.Persistence.Models; @@ -13,6 +14,7 @@ using System.Diagnostics; using System.IO; using System.Threading.Tasks; +using Windows.UI; using Windows.UI.Text; namespace FluentMath.Views @@ -42,6 +44,15 @@ public sealed partial class SettingsPage : Page private const double DialogMaxWidthShare = 0.9; // of the window width private const double DialogPlatformMinWidth = 320; // ContentDialogMinWidth; (not a knob) + // --- update badge --- + // literals, not theme resources: a status colour means the same in both themes, and a white glyph on a + // coloured plate reads on both; the accent states use the system accent + private static readonly Color SuccessColor = Color.FromArgb(0xFF, 0x4C, 0xA2, 0x2E); + private static readonly Color CautionColor = Color.FromArgb(0xFF, 0xC1, 0x8A, 0x1B); + private static readonly Color CriticalColor = Color.FromArgb(0xFF, 0xC4, 0x3E, 0x1C); + private static readonly Color NeutralColor = Color.FromArgb(0xFF, 0x6B, 0x6B, 0x6B); + private static readonly Color AccentFallbackColor = Color.FromArgb(0xFF, 0x00, 0x78, 0xD4); + public SettingsPage() { InitializeComponent(); @@ -56,8 +67,17 @@ public SettingsPage() RestoreCalculatorSettings(); RestoreConverterSettings(); AppDataFolderCard.Description = PersistenceService.Instance.RootFolder; - VersionTextBlock.Text = VersionLabel(); + VersionTextBlock.Text = UpdateService.VersionLabel(UpdateService.CurrentVersion); + CheckUpdatesToggle.IsOn = SettingsService.Instance.CheckUpdatesOnStartup; _isLoading = false; + + // the page is built anew on every visit, so it follows the update service only while shown + Loaded += (_, _) => + { + UpdateService.Instance.UpdateStateChanged += RefreshUpdateState; + RefreshUpdateState(); + }; + Unloaded += (_, _) => UpdateService.Instance.UpdateStateChanged -= RefreshUpdateState; } @@ -327,13 +347,108 @@ private ContentDialog NewDialog(string title, string message) } - // === about === + // === updates === + + // the pill and the state card, pulled from the service on every change + private void RefreshUpdateState() + { + UpdateService service = UpdateService.Instance; + + string versionLabel = UpdateService.VersionLabel(service.Latest?.Version); + UpdatePillText.Text = versionLabel.Length > 0 ? versionLabel : "Update"; + UpdatePillButton.Visibility = service.IsUpdateAvailable ? Visibility.Visible : Visibility.Collapsed; + + bool checking = service.UiState == UpdateUiState.Checking; + UpdateCheckingRing.IsActive = checking; + UpdateCheckingRing.Visibility = checking ? Visibility.Visible : Visibility.Collapsed; + UpdateBadge.Visibility = checking ? Visibility.Collapsed : Visibility.Visible; + UpdateStatusCard.IsEnabled = !checking; + + string lastChecked = service.LastCheckedAt.HasValue + ? $"Last checked {service.LastCheckedAt.Value:dd.MM. HH:mm}" + : "Not checked yet"; + + switch (service.UiState) + { + case UpdateUiState.UpToDate: + SetUpdateState("\uE73E", SuccessColor, "You are up to date", lastChecked); + break; + + case UpdateUiState.Checking: + SetUpdateState("\uE895", AccentColor(), "Checking for updates", ""); + break; + + case UpdateUiState.UpdateAvailable: + // a store update without a GitHub name has no version + SetUpdateState("\uE896", AccentColor(), "Update available", versionLabel.Length > 0 + ? $"{versionLabel} is ready to install" + : "A new version is ready to install"); + break; + + case UpdateUiState.Skipped: + SetUpdateState("\uE7BA", CautionColor, $"{UpdateService.VersionLabel(service.SkippedVersion)} skipped", + "Select to install it anyway"); + break; + + case UpdateUiState.Failed: + SetUpdateState("\uE711", CriticalColor, "Check failed", AppDistribution.SupportsSelfUpdate + ? "Could not reach GitHub, select to try again" + : "Could not reach the Microsoft Store, select to try again"); + break; + + default: + SetUpdateState("\uE895", NeutralColor, "Check for updates", lastChecked); + break; + } + } + + private void SetUpdateState(string glyph, Color color, string title, string description) + { + UpdateBadgeIcon.Glyph = glyph; + UpdateBadge.Background = new SolidColorBrush(color); + UpdateStatusCard.Header = title; + UpdateStatusCard.Description = description; + } + + // the Windows accent, else the WinUI default + private static Color AccentColor() => + Application.Current.Resources.TryGetValue("SystemAccentColor", out object value) && value is Color color + ? color + : AccentFallbackColor; + + // one card, three jobs, by service state + private async void UpdateStatusCard_Click(object sender, RoutedEventArgs e) + { + UpdateService service = UpdateService.Instance; + + switch (service.UiState) + { + case UpdateUiState.UpdateAvailable: + await UpdateDialog.ShowAsync(XamlRoot, service.Latest); + break; + + case UpdateUiState.Skipped: + // the way back out of a skip + service.ClearSkippedVersion(); + await UpdateDialog.ShowAsync(XamlRoot, service.Latest); + break; + + default: + await service.CheckAsync(); + break; + } + } + + private async void UpdatePillButton_Click(object sender, RoutedEventArgs e) + { + await UpdateDialog.ShowAsync(XamlRoot, UpdateService.Instance.Latest); + } - // the running version as v2.2.0, from in the csproj - private static string VersionLabel() + private void CheckUpdatesToggle_Toggled(object sender, RoutedEventArgs e) { - Version? version = typeof(App).Assembly.GetName().Version; - return version == null ? "" : $"v{version.Major}.{version.Minor}.{Math.Max(version.Build, 0)}"; + if (_isLoading) return; + + SettingsService.Instance.CheckUpdatesOnStartup = CheckUpdatesToggle.IsOn; } diff --git a/FluentMath/Views/UpdateDialog.cs b/FluentMath/Views/UpdateDialog.cs new file mode 100644 index 0000000..a50b3ca --- /dev/null +++ b/FluentMath/Views/UpdateDialog.cs @@ -0,0 +1,313 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using FluentMath.Distribution; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace FluentMath.Views +{ + // the update dialog: + // the step between a new release and replacing the app; built in code like the settings page dialogs, and one + // instance survives the download (the primary button cancels its own close, the progress bar takes over) + // the store build shows it too, the store installs and the manual way is the store page + public static class UpdateDialog + { + // === fields === + + private const double MaxWidthShare = 0.9; // of the window width + private const double PlatformMinWidth = 320; // ContentDialogMinWidth; (not a knob) + + + // === public api === + + // three ways forward (the app does it, you do it, neither) and the checkbox as a modifier on the exit; Close is + // the neutral way out, ESC included + public static async Task ShowAsync(XamlRoot? xamlRoot, UpdateInfo? info) + { + if (xamlRoot == null || info == null) return; + + bool isStoreBuild = !AppDistribution.SupportsSelfUpdate; + + // empty for a store update without a GitHub name, see UpdateService.AskStoreAsync + bool hasVersion = !string.IsNullOrEmpty(info.Version); + string current = UpdateService.VersionLabel(UpdateService.CurrentVersion); + + // remembered across restarts, the settings update card undoes it; (a nameless version cannot be skipped) + var skipCheckBox = new CheckBox + { + Content = "Skip this version", + Margin = new Thickness(0, 12, 0, 0), + Visibility = hasVersion ? Visibility.Visible : Visibility.Collapsed + }; + + var progress = new ProgressBar + { + Margin = new Thickness(0, 8, 0, 0), + Maximum = 1, + Visibility = Visibility.Collapsed + }; + + var statusText = new TextBlock + { + Margin = new Thickness(0, 8, 0, 0), + Visibility = Visibility.Collapsed, + TextWrapping = TextWrapping.Wrap + }; + + var content = new StackPanel(); + content.Children.Add(new TextBlock + { + Text = hasVersion + ? $"Fluent Math {UpdateService.VersionLabel(info.Version)} is available. You are running {current}." + : $"A new version of Fluent Math is available. You are running {current}.", + TextWrapping = TextWrapping.Wrap + }); + content.Children.Add(skipCheckBox); + content.Children.Add(progress); + content.Children.Add(statusText); + + // a dialog sits beside the window content, so the theme the app sets there is handed over by hand + var dialog = new ContentDialog + { + Title = "Update available", + Content = content, + PrimaryButtonText = "Update", + SecondaryButtonText = isStoreBuild ? "Open Store" : "Manual Install", + CloseButtonText = "Close", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = xamlRoot, + RequestedTheme = xamlRoot.Content is FrameworkElement root ? root.ActualTheme : ElementTheme.Default + }; + + // capped to a share of the window, the floor lowered with it for a narrow one + double maxWidth = xamlRoot.Size.Width * MaxWidthShare; + dialog.Resources["ContentDialogMaxWidth"] = maxWidth; + dialog.Resources["ContentDialogMinWidth"] = Math.Min(PlatformMinWidth, maxWidth); + + // set while a download runs, which turns Close into Cancel + CancellationTokenSource? downloadCts = null; + bool handedOverToScript = false; + + // store build: the store replaces the package, nothing can cancel that + bool storeIsInstalling = false; + + void SetDownloading(bool downloading) + { + dialog.IsPrimaryButtonEnabled = !downloading; + dialog.IsSecondaryButtonEnabled = !downloading; + dialog.CloseButtonText = downloading ? "Cancel" : "Close"; + skipCheckBox.IsEnabled = !downloading; + progress.Visibility = downloading ? Visibility.Visible : Visibility.Collapsed; + } + + // no GetDeferral(): a pending deferral makes the dialog ignore every further press, the cancel included; + // with Cancel=true there is nothing to defer + dialog.PrimaryButtonClick += async (_, args) => + { + // stays on screen as the progress readout + args.Cancel = true; + + downloadCts = new CancellationTokenSource(); + SetDownloading(true); + statusText.Visibility = Visibility.Visible; + + if (isStoreBuild) + { + // stays until the store ends the process; the close button goes once nothing can be cancelled + await RunStoreUpdateAsync(progress, statusText, () => + { + storeIsInstalling = true; + dialog.CloseButtonText = ""; + }, downloadCts.Token); + + storeIsInstalling = false; + downloadCts.Dispose(); + downloadCts = null; + SetDownloading(false); + return; + } + + statusText.Text = $"Downloading {info.AssetName}..."; + + bool handedOver = await RunUpdateAsync(info, progress, statusText, downloadCts.Token); + + downloadCts.Dispose(); + downloadCts = null; + + if (handedOver) + { + // the replacement script waits for this process to end + handedOverToScript = true; + dialog.Hide(); + MainWindow.Instance?.ExitForUpdate(); + return; + } + + SetDownloading(false); + }; + + // stays open, the browser (or the store page) opens beside it + dialog.SecondaryButtonClick += (sender, args) => + { + args.Cancel = true; + + if (isStoreBuild) _ = AppDistribution.OpenStorePageAsync(); + else OpenReleasePage(info.ReleaseUrl); + }; + + // Closing, not CloseButtonClick, so ESC takes the same path: past the abort a download would run unseen, + // past the checkbox a tick would be lost + dialog.Closing += (_, args) => + { + if (storeIsInstalling) + { + args.Cancel = true; + return; + } + + if (downloadCts != null) + { + // mid-download the abort, not the exit; (a slow line holds the dialog for minutes) + args.Cancel = true; + downloadCts.Cancel(); + return; + } + + // never skip the version being installed + if (handedOverToScript) return; + + if (skipCheckBox.IsChecked == true) UpdateService.Instance.SkipVersion(); + }; + + await dialog.ShowAsync(); + } + + + // === private helpers === + + // true once the handoff script owns the update and the app has to exit + private static async Task RunUpdateAsync( + UpdateInfo info, ProgressBar progress, TextBlock statusText, CancellationToken ct) + { + try + { + var reporter = new Progress(fraction => progress.Value = fraction); + string? downloadedPath = await UpdateInstaller.DownloadAsync(info, reporter, ct); + + if (string.IsNullOrEmpty(downloadedPath)) + { + // no asset for this build; the release page + OpenReleasePage(info.ReleaseUrl); + statusText.Text = "This release has no matching download, opening the release page instead"; + return false; + } + + statusText.Text = "Installing, the app will restart on its own"; + progress.IsIndeterminate = true; + + UpdateInstaller.ApplyAndRestart(downloadedPath); + return true; + } + catch (OperationCanceledException) + { + // asked for, so no browser and no error wording + statusText.Text = "Download cancelled"; + ResetProgress(progress); + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"[UpdateDialog] update failed: {ex.Message}"); + statusText.Text = "The download failed, you can grab the release manually instead"; + ResetProgress(progress); + OpenReleasePage(info.ReleaseUrl); + return false; + } + } + + // the store counterpart of RunUpdateAsync; returns once the store gave up or finished (a finished install + // usually ends the process first); onInstalling fires when the package is replaced + private static async Task RunStoreUpdateAsync( + ProgressBar progress, TextBlock statusText, Action onInstalling, CancellationToken ct) + { + // reports are posted, a late one could land after the result + bool isFinished = false; + + var reporter = new Progress(step => + { + if (isFinished) return; + + switch (step.Phase) + { + case StoreInstallPhase.Waiting: + statusText.Text = "Waiting for the Microsoft Store..."; + progress.IsIndeterminate = true; + break; + + case StoreInstallPhase.Downloading: + // 0 for a few seconds before the first bytes + statusText.Text = "Downloading from the Microsoft Store..."; + progress.IsIndeterminate = step.Fraction <= 0; + progress.Value = step.Fraction; + break; + + case StoreInstallPhase.Installing: + statusText.Text = "Installing, the app will restart on its own"; + progress.IsIndeterminate = true; + onInstalling(); + break; + } + }); + + statusText.Text = "Waiting for the Microsoft Store..."; + progress.IsIndeterminate = true; + + StoreInstallResult result; + try + { + result = await StoreUpdateSource.InstallAsync(reporter, ct); + } + catch (Exception ex) + { + Debug.WriteLine($"[UpdateDialog] store update failed: {ex.Message}"); + result = StoreInstallResult.Failed; + } + + isFinished = true; + ResetProgress(progress); + + if (result == StoreInstallResult.Installed) + { + statusText.Text = "Update installed, it takes effect the next time the app starts"; + return; + } + + // a cancel nobody asked for is the store giving up, a failure + if (result == StoreInstallResult.Canceled && ct.IsCancellationRequested) + { + statusText.Text = "Download cancelled"; + return; + } + + statusText.Text = "The Microsoft Store could not install the update, opening the Store instead"; + _ = AppDistribution.OpenStorePageAsync(); + } + + private static void ResetProgress(ProgressBar progress) + { + progress.IsIndeterminate = false; + progress.Value = 0; + } + + private static void OpenReleasePage(string url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch { /* no browser; the dialog names the version anyway */ } + } + } +} From a8882bff6bc0c13e92bad48948910d2caddddfb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C4=8Cech?= Date: Tue, 6 Oct 2026 02:10:32 +0200 Subject: [PATCH 2/4] fix: keep the title and the update pill in a narrow title bar and align the update card --- FluentMath/MainWindow.xaml | 122 +++++++++++++++++------------ FluentMath/MainWindow.xaml.cs | 98 ++++++++++++++++++++--- FluentMath/Views/SettingsPage.xaml | 6 +- 3 files changed, 164 insertions(+), 62 deletions(-) diff --git a/FluentMath/MainWindow.xaml b/FluentMath/MainWindow.xaml index 3b5606a..3319361 100644 --- a/FluentMath/MainWindow.xaml +++ b/FluentMath/MainWindow.xaml @@ -20,71 +20,95 @@ - - + - - - - 32 - - - - - + SizeChanged="AppTitleBar_SizeChanged"> + + + + + + + + + - - - + + + + + + + - - - - + +