Skip to content
Open
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
96 changes: 80 additions & 16 deletions backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using Windows.Management.Deployment;
using Windows.Networking.Connectivity;
using LexCore.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.Windows.AppLifecycle;
using FwLiteShared.AppUpdate;
using FwLiteShared.Events;

Expand All @@ -13,14 +15,22 @@ namespace FwLiteMaui;
public class AppUpdateService(ILogger<AppUpdateService> logger, IPreferences preferences, GlobalEventBus eventBus)
: IMauiInitializeService, IPlatformUpdateService
{
//Must keep the .appinstaller file name: AddPackageByAppInstallerFileAsync rejects any other URI.
//Kept in sync with FwLiteReleaseService.AppInstallerUrl, which the server bakes into the file itself.
private const string AppInstallerUrl = "https://lexbox.org/api/fwlite-release/FieldWorksLite.appinstaller";
//Staged but not registered because this app is still running. Windows registers it at the next
//activation, so it's a pending update rather than a failure.
private const int ErrorPackagesInUse = unchecked((int)0x80073D02);
private const string LastUpdateCheckKey = "lastUpdateChecked";
private const string NotificationIdKey = "notificationId";
private const string ActionKey = "action";
private const string ResultRefKey = "resultRef";
private static readonly Dictionary<string, TaskCompletionSource<string?>> NotificationCompletionSources = new();
private IServiceProvider? _services;

public void Initialize(IServiceProvider services)
{
_services = services;
ToastNotificationManagerCompat.OnActivated += toastArgs =>
{
ToastArguments args = ToastArguments.Parse(toastArgs.Argument);
Expand Down Expand Up @@ -113,14 +123,19 @@ private async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease, bool q
//must update through the App Installer API. Updating the raw bundle via AddPackageByUriAsync
//would detach it from that track (see App Installer non-store update docs). Installs from a plain
//.msixbundle - how most users are installed today - have no association and take the fallback path.
var appInstallerUri = GetAppInstallerUri();
IAsyncOperationWithProgress<DeploymentResult, DeploymentProgress> asyncOperation;
if (appInstallerUri is not null)
if (IsOnAppInstallerTrack())
{
//Not the URI the package recorded: the API rejects any URI whose file name isn't *.appinstaller,
//and installs attached before the FieldWorksLite.appinstaller route existed recorded a query-string URL.
var appInstallerUri = new Uri(AppInstallerUrl);
logger.LogInformation("Updating via App Installer file {AppInstallerUri}", appInstallerUri);
//ForceUpdateFromAnyVersion is controlled by the .appinstaller XML, not these options.
//Never ForceTargetAppShutdown: that terminates this process without letting us close FwData
//projects. None stages the update instead and Windows registers it at the next activation, which
//is the same outcome the OS background updater produces. ForceUpdateFromAnyVersion is controlled
//by the .appinstaller XML, not these options.
asyncOperation = packageManager.AddPackageByAppInstallerFileAsync(appInstallerUri,
quitOnUpdate ? AddPackageByAppInstallerOptions.ForceTargetAppShutdown : AddPackageByAppInstallerOptions.None,
AddPackageByAppInstallerOptions.None,
packageManager.GetDefaultPackageVolume());
}
else
Expand Down Expand Up @@ -148,20 +163,69 @@ private async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease, bool q
//note this asyncOperation is not reliable, it's possible the update will install and this will never resolve
var updateTask = asyncOperation.AsTask();
var completedTask = await Task.WhenAny(updateTask, Task.Delay(TimeSpan.FromMinutes(2)));
if (completedTask == updateTask)
if (completedTask == updateTask) return InterpretUpdateResult(await updateTask, latestRelease);

//deployment carries on in AppXSvc after we stop waiting, so record how it ends instead of dropping the
//result: an update that fails here is otherwise indistinguishable from one that worked
_ = LogOutcomeWhenDone();
return UpdateResult.Started;

async Task LogOutcomeWhenDone()
{
var result = await updateTask;
if (!string.IsNullOrEmpty(result.ErrorText))
try
{
//VSTHRD003: nothing awaits this local function, so awaiting a task from the enclosing scope
//can't deadlock a caller
#pragma warning disable VSTHRD003
InterpretUpdateResult(await updateTask, latestRelease);
#pragma warning restore VSTHRD003
}
catch (Exception e)
{
logger.LogError(result.ExtendedErrorCode, "Failed to download update: {ErrorText}", result.ErrorText);
return UpdateResult.Failed;
//nobody awaits this, so an exception would otherwise surface as an unobserved task exception
logger.LogError(e, "Update to {Version} failed after we stopped waiting on it", latestRelease.Version);
}
}
}

logger.LogInformation("Update downloaded, will install on next restart");
private UpdateResult InterpretUpdateResult(DeploymentResult result, FwLiteRelease latestRelease)
{
if (result.ExtendedErrorCode?.HResult == ErrorPackagesInUse)
{
logger.LogInformation("Update to {Version} is staged, Windows will register it once this app closes",
latestRelease.Version);
return UpdateResult.Success;
}

return UpdateResult.Started;
if (!string.IsNullOrEmpty(result.ErrorText))
{
logger.LogError(result.ExtendedErrorCode, "Failed to download update: {ErrorText}", result.ErrorText);
return UpdateResult.Failed;
}

logger.LogInformation("Update downloaded, will install on next restart");
return UpdateResult.Success;
}

/// <remarks>
/// AppInstance.Restart terminates the process outright rather than running MAUI's shutdown, so stop the
/// hosted services first: that's what closes open FwData projects. It only returns if the restart failed.
/// </remarks>
public async Task RestartForUpdate()
{
logger.LogInformation("Restarting so Windows can register the staged update");
if (_services?.GetService<FwLiteMauiKernel.HostedServiceAdapter>() is { } hostedServices)
{
await hostedServices.DisposeAsync();
}

await MainThread.InvokeOnMainThreadAsync(() =>
{
var failureReason = AppInstance.Restart(string.Empty);
logger.LogError("Restart failed ({FailureReason}), closing instead so the update still installs",
failureReason);
Application.Current?.Quit();
});
}

private void NotifyInstallProgress(uint percentage, FwLiteRelease release)
Expand All @@ -170,20 +234,20 @@ private void NotifyInstallProgress(uint percentage, FwLiteRelease release)
}

/// <summary>
/// The .appinstaller URI this install is associated with, or null if it wasn't installed via an
/// .appinstaller file (i.e. it's not on the OS update track and should use the direct-bundle path).
/// Whether this install was deployed from an .appinstaller file, i.e. it's on the OS update track and
/// must be updated through the App Installer API rather than the direct-bundle path.
/// </summary>
private Uri? GetAppInstallerUri()
private bool IsOnAppInstallerTrack()
{
try
{
return Windows.ApplicationModel.Package.Current.GetAppInstallerInfo()?.Uri;
return Windows.ApplicationModel.Package.Current.GetAppInstallerInfo() is not null;
}
catch (Exception e)
{
//Package.Current throws for unpackaged/portable apps; treat as "not on the track".
logger.LogWarning(e, "Unable to read App Installer info; falling back to direct bundle update");
return null;
return false;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,11 @@ public Task<bool> RequestPermissionToUpdate(FwLiteRelease latestRelease)
{
return Task.FromResult(true);
}

public Task RestartForUpdate()
{
//unreachable: only the Windows MSIX app stages updates, and only it reports UpdateResult.Success,
//which is the only state that offers this to the user
throw new NotSupportedException("This platform doesn't restart itself to finish an update");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public interface IPlatformUpdateService
bool SupportsAutoUpdate { get; }
Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease);
Task<bool> RequestPermissionToUpdate(FwLiteRelease latestRelease);

/// <summary>
/// Restarts the app so the OS can finish installing an update that's staged and waiting on it. Windows
/// otherwise force-terminates the app to do this at some later activation.
/// </summary>
Task RestartForUpdate();
}

[JsonConverter(typeof(JsonStringEnumConverter))]
Expand Down
8 changes: 7 additions & 1 deletion backend/FwLite/FwLiteShared/Services/UpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace FwLiteShared.Services;

public class UpdateService(UpdateChecker updateChecker)
public class UpdateService(UpdateChecker updateChecker, IPlatformUpdateService platformUpdateService)
{
[JSInvokable]
public Task<AvailableUpdate?> CheckForUpdates()
Expand All @@ -17,4 +17,10 @@ public Task<UpdateResult> ApplyUpdate(AvailableUpdate update)
{
return Task.Run(async () => await updateChecker.ApplyUpdate(update.Release));
}

[JSInvokable]
public async Task RestartForUpdate()
{
await platformUpdateService.RestartForUpdate();
}
}
13 changes: 13 additions & 0 deletions backend/LexBoxApi/Controllers/FwLiteReleaseController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ public async Task<ActionResult> DownloadLatest([FromQuery] FwLiteEdition edition
return Redirect(latestRelease.Url);
}

//AddPackageByAppInstallerFileAsync rejects any URI whose file name doesn't end in .appinstaller, so
//the client can't use download-latest?edition=windowsAppInstaller. That route stays for packages
//already recording it as their update source.
[HttpGet("FieldWorksLite.appinstaller")]
[AllowAnonymous]
public async Task<ActionResult> AppInstaller()
{
using var activity = LexBoxActivitySource.Get().StartActivity();
activity?.AddTag(FwLiteReleaseService.FwLiteEditionTag, FwLiteEdition.WindowsAppInstaller.ToString());
var appInstallerContent = await releaseService.GenerateAppInstaller();
return File(Encoding.UTF8.GetBytes(appInstallerContent), "application/appinstaller", "FieldWorksLite.appinstaller");
}

[HttpGet("latest")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ namespace LexBoxApi.Services.FwLiteReleases;
public class FwLiteReleaseService(IHttpClientFactory factory, HybridCache cache, IOptions<FwLiteReleaseConfig> config)
{
public const string HttpClientName = "Github";
//Baked into every install as the update source, so it must stay stable. The .appinstaller file name
//is load-bearing: AddPackageByAppInstallerFileAsync rejects a URI without that extension.
public const string AppInstallerUrl = "https://lexbox.org/api/fwlite-release/FieldWorksLite.appinstaller";
private const string GithubLatestRelease = "GithubLatestRelease";
public const string FwLiteClientVersionTag = "app.fw-lite.client.version";
public const string FwLiteEditionTag = "app.fw-lite.edition";
Expand Down Expand Up @@ -111,7 +114,7 @@ public async ValueTask<string> GenerateAppInstaller(CancellationToken token = de
return $"""
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller
Uri="https://lexbox.org/api/fwlite-release/download-latest?edition=windowsAppInstaller"
Uri="{AppInstallerUrl}"
Version="{version}"
xmlns="http://schemas.microsoft.com/appx/appinstaller/2021">
<MainBundle
Expand Down
9 changes: 9 additions & 0 deletions backend/Testing/LexCore/Services/FwLiteReleaseServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,13 @@ public void ConvertVersionToAppInstallerVersionGivesExpectedResult(string tag, s
{
FwLiteReleaseService.ConvertVersionToAppInstallerVersion(tag).Should().Be(expected);
}

[Fact]
public async Task GeneratedAppInstallerPointsAtADotAppInstallerUrl()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method with return type `Task` does not follow the naming convention


The consensus in .NET is to have names of methods dealing with asynchronous operations suffixed with Async. One such example is Stream.ReadAsync from System.IO. Doing so improves readability and provides crucial information at a glance.

{
//AddPackageByAppInstallerFileAsync throws ArgumentException on a URI without this extension
var appInstaller = await _fwLiteReleaseService.GenerateAppInstaller();
appInstaller.Should().Contain($"Uri=\"{FwLiteReleaseService.AppInstallerUrl}\"");
FwLiteReleaseService.AppInstallerUrl.Should().EndWith(".appinstaller");
}
}
32 changes: 32 additions & 0 deletions docs/research/msix-appinstaller-fwlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,35 @@ These were the open items at research time. All three were tested manually and *
- [Troubleshoot installation issues with the App Installer file](https://learn.microsoft.com/en-us/windows/msix/app-installer/troubleshoot-appinstaller-issues) — Content-Type "correct MIME type" requirement; Content-Length required on GET and HEAD (0x80072F76); `Add-AppxPackage -AppInstaller` local test; vanity-URL/redirect restriction for `ms-appinstaller`; per-build sideload feature table; trusted-cert store guidance.
- [Package.GetAppInstallerInfo](https://learn.microsoft.com/en-us/uwp/api/windows.applicationmodel.package.getappinstallerinfo) and [AppInstallerInfo.UpdateUris](https://learn.microsoft.com/en-us/uwp/api/windows.applicationmodel.appinstallerinfo.updateuris) — inspect the per-package AppInstaller association / update URIs (1–10).
- **Live header check (corroborating, not first-party docs):** `HEAD` of the current FW Lite release asset `https://github.com/sillsdev/languageforge-lexbox/releases/download/v2026-07-06-915ca19d/FieldWorksLiteInstaller.msixbundle` → `HTTP 200`, `Accept-Ranges: bytes`, `Content-Length: 172556267`, `Content-Type: application/octet-stream`. Confirms GitHub's CDN supports range requests and correct Content-Length but serves the bundle as octet-stream.

---

## Correction (2026-08-18): the in-app updater never worked on the track

`AddPackageByAppInstallerFileAsync` rejects any URI whose file name doesn't end in `.appinstaller`,
throwing `ArgumentException` synchronously before it touches the network. The URL this doc shipped,
`download-latest?edition=windowsAppInstaller`, sets that name only via `Content-Disposition`, so every
manual test (download the file, double-click) passed while every in-app update failed from #2496 until
the `FieldWorksLite.appinstaller` route was added. Redirects to a `.appinstaller` URL are documented to
fail the same way, so the route serves the content directly.

What the two paths actually do, from a tester machine's deployment log:

- **The OS background task works and is not subject to that rule.** It updates through the URI recorded
at install time, staging the *bundle* (`StagePackage`), then registering at the next activation —
force-terminating a running instance to do so (`0x80073D02` until then). Why the API check doesn't
apply to it isn't documented; treat that as inference.
- **`0x80073D02` is not a failure.** It means the update staged but couldn't register while the app runs.
Windows registers it at the next activation, so it's the "installs when you close the app" outcome.

Constraints worth not re-discovering:

- There is no stage-only or deferred-registration API for `.appinstaller` files. `AddPackageByAppInstallerOptions`
is only `None`, `InstallAllResources`, `ForceTargetAppShutdown`, `RequiredContentGroupOnly`,
`LimitToExistingPackages`, and the WindowsAppSDK `PackageDeploymentManager` (which does have
`DeferRegistrationWhenPackagesAreInUse`) can't consume `.appinstaller` files at all.
- The `packageManagement` restricted capability is **not** needed to update our own same-publisher package:
"this is required for cross-publisher scenario, but managing your own app should work without having to
declare the capability" ([non-store developer updates](https://learn.microsoft.com/en-us/windows/msix/non-store-developer-updates)).
- `PackageCatalog.PackageUpdating` is telemetry only. No veto, no delay, and no documented guarantee that it
fires before a forced shutdown, so don't build save-my-work-first logic on it.
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export interface IUpdateService
{
checkForUpdates() : Promise<IAvailableUpdate | undefined>;
applyUpdate(update: IAvailableUpdate) : Promise<UpdateResult>;
restartForUpdate() : Promise<void>;
}
/* eslint-enable */
3 changes: 2 additions & 1 deletion frontend/viewer/src/lib/updates/UpdateDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
{checkPromise}
{installPromise}
{installUpdate}
{installProgress} />
{installProgress}
restartApp={() => updateService.restartForUpdate()} />

<div class="flex justify-center gap-3">
<Anchor
Expand Down
13 changes: 11 additions & 2 deletions frontend/viewer/src/lib/updates/UpdateDialogContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
installPromise?: Promise<UpdateResult>;
installUpdate: (update: IAvailableUpdate) => Promise<void>;
installProgress?: number;
restartApp?: () => Promise<void>;
}

let {
checkPromise,
installPromise,
installUpdate,
installProgress
installProgress,
restartApp
}: Props = $props();

</script>
Expand Down Expand Up @@ -55,7 +57,8 @@
<div class="flex items-center gap-4 p-4 rounded-lg bg-muted">
{#if updateResult === UpdateResult.Success}
<Icon icon="i-mdi-check-circle" />
<p>{$t`Update installed successfully! Please restart the application.`}</p>
<!-- The update is downloaded, but Windows can only swap it in while the app isn't running -->
<p>{$t`Update downloaded. FieldWorks Lite needs to restart to finish installing it.`}</p>
{:else if updateResult === UpdateResult.Started}
<Icon icon="i-mdi-information" />
<!-- Apparently there's some unreliability in the update process.
Expand All @@ -80,6 +83,12 @@
<XButton onclick={() => installPromise = undefined} class="ml-auto border"/>
{/if}
</div>
{#if updateResult === UpdateResult.Success && restartApp}
<!-- Restarting now is a clean shutdown; leaving it running means Windows force-closes the app itself later -->
<Button onclick={restartApp} class="w-full" icon="i-mdi-restart">
{$t`Restart now`}
</Button>
{/if}
{/await}
{:else if checkPromise}
{#await checkPromise then availableUpdate}
Expand Down
Loading
Loading