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..2696707 100644 --- a/FluentMath/MainWindow.xaml +++ b/FluentMath/MainWindow.xaml @@ -20,41 +20,93 @@ - - + - - - - 32 - - - - - + SizeChanged="AppTitleBar_SizeChanged"> + + + + + + + + + - - - - + + + + + + + + + + + +