Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions FluentMath.Tests/SettingsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
46 changes: 46 additions & 0 deletions FluentMath/Distribution/AppDistribution.cs
Original file line number Diff line number Diff line change
@@ -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 ===
Expand Down Expand Up @@ -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()
Expand Down
136 changes: 136 additions & 0 deletions FluentMath/Distribution/StoreUpdateSource.cs
Original file line number Diff line number Diff line change
@@ -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<StorePackageUpdate>? _updates;


// === public api ===

// throws on an unreachable store (a failed check); ownerWindow carries the store context, see GetContext
public static async Task<bool> 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<StoreInstallResult> InstallAsync(IProgress<StoreInstallProgress> 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<StorePackageUpdateStatus>(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
};
}
}
Loading
Loading