Skip to content

Commit

Permalink
Implement auto update in Fieldworks Lite (#1298)
Browse files Browse the repository at this point in the history
* create api to redirect to the latest GitHub release of fw lite

* invalidate lexbox release cache when a new version is released

* implement api to check for the latest version and queue an update

* validate the time since last update check and ignore it if it's in the future

* ensure InformationalVersion is set

* create a desktop shortcut on first launch

* move Windows-specific code into the Windows platform folder

* refactor update checking code to delegate the update decision to the server, if the server returns a release, then the client will update to that version

* don't reference the install path when making shortcut since it will change after an update

* add a default value for GetLatestRelease cancellation token
  • Loading branch information
hahn-kev authored Dec 9, 2024
1 parent 1b7fd5a commit ce28810
Show file tree
Hide file tree
Showing 18 changed files with 668 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/fw-lite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,9 @@ jobs:
fw-lite-msix/*
fw-lite-portable.zip
fw-lite-local-web-app-linux.zip
- name: Invalidate Lexbox Release endpoint
continue-on-error: true
run: |
sleep 10
curl -X POST https://lexbox.org/api/fwlite-release/new-release
11 changes: 10 additions & 1 deletion backend/FwLite/FwLiteDesktop/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

public partial class App : Application
{
private readonly MainPage _mainPage;

public App(MainPage mainPage)
{
_mainPage = mainPage;
InitializeComponent();
}

MainPage = mainPage;
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(_mainPage)
{
Title = "FieldWorks Lite " + AppVersion.Version
};
}
}
9 changes: 9 additions & 0 deletions backend/FwLite/FwLiteDesktop/AppVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Reflection;

namespace FwLiteDesktop;

public class AppVersion
{
public static readonly string Version = typeof(AppVersion).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "dev";
}
2 changes: 2 additions & 0 deletions backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- version used in AppVersion.cs-->
<InformationalVersion>$(ApplicationDisplayVersion)</InformationalVersion>

<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">11.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">13.1</SupportedOSPlatformVersion>
Expand Down
10 changes: 9 additions & 1 deletion backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Windows.ApplicationModel;
using System.Runtime.InteropServices;
using Windows.ApplicationModel;
using FwLiteDesktop.ServerBridge;
using FwLiteShared.Auth;
using LcmCrdt;
Expand All @@ -22,6 +23,7 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services,
#if DEBUG
environment = "Development";
#endif

var defaultDataPath = IsPackagedApp ? FileSystem.AppDataDirectory : Directory.GetCurrentDirectory();
var baseDataPath = Path.GetFullPath(configuration.GetSection("FwLiteDesktop").GetValue<string>("BaseDataDir") ?? defaultDataPath);
Directory.CreateDirectory(baseDataPath);
Expand All @@ -41,7 +43,13 @@ public static void AddFwLiteDesktopServices(this IServiceCollection services,
//using a lambda here means that the serverManager will be disposed when the app is disposed
services.AddSingleton<ServerManager>(_ => serverManager);
services.AddSingleton<IMauiInitializeService>(_ => _.GetRequiredService<ServerManager>());
services.AddHttpClient();


services.AddSingleton<IHostEnvironment>(_ => _.GetRequiredService<ServerManager>().WebServices.GetRequiredService<IHostEnvironment>());
services.AddSingleton<IPreferences>(Preferences.Default);
services.AddSingleton<IVersionTracking>(VersionTracking.Default);
services.AddSingleton<IConnectivity>(Connectivity.Current);
configuration.Add<ServerConfigSource>(source => source.ServerManager = serverManager);
services.AddOptions<LocalWebAppConfig>().BindConfiguration("LocalWebApp");
logging.AddFile(Path.Combine(baseDataPath, "app.log"));
Expand Down
9 changes: 8 additions & 1 deletion backend/FwLite/FwLiteDesktop/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FwLiteDesktop.ServerBridge;
using FwLiteDesktop.WinUI;
using LcmCrdt;
using LocalWebApp;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -26,14 +27,20 @@ public static MauiApp CreateMauiApp()
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
builder.ConfigureEssentials(essentialsBuilder =>
{
essentialsBuilder.UseVersionTracking();
});
builder.ConfigureLifecycleEvents(events => events.AddWindows(windowsEvents =>
{
windowsEvents.OnClosed((window, args) =>
{
holder.App?.Services.GetRequiredService<ServerManager>().Stop();
});
}));

#if WINDOWS
builder.AddFwLiteWindows();
#endif
builder.Services.AddFwLiteDesktopServices(builder.Configuration, builder.Logging);

holder.App = builder.Build();
Expand Down
117 changes: 117 additions & 0 deletions backend/FwLite/FwLiteDesktop/Platforms/Windows/AppUpdateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Buffers;
using System.Net.Http.Json;
using Windows.Management.Deployment;
using LexCore.Entities;
using Microsoft.Extensions.Logging;

namespace FwLiteDesktop;

public class AppUpdateService(
IHttpClientFactory httpClientFactory,
ILogger<AppUpdateService> logger,
IPreferences preferences,
IConnectivity connectivity) : IMauiInitializeService
{
private const string LastUpdateCheck = "lastUpdateChecked";
private const string FwliteUpdateUrlEnvVar = "FWLITE_UPDATE_URL";
private const string ForceUpdateCheckEnvVar = "FWLITE_FORCE_UPDATE_CHECK";
private const string PreventUpdateCheckEnvVar = "FWLITE_PREVENT_UPDATE";

private static readonly SearchValues<string> ValidPositiveEnvVarValues =
SearchValues.Create(["1", "true", "yes"], StringComparison.OrdinalIgnoreCase);

private static readonly string ShouldUpdateUrl = Environment.GetEnvironmentVariable(FwliteUpdateUrlEnvVar) ??
$"https://lexbox.org/api/fwlite-release/should-update?appVersion={AppVersion.Version}";

public void Initialize(IServiceProvider services)
{
_ = Task.Run(TryUpdate);
}

private async Task TryUpdate()
{
if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(PreventUpdateCheckEnvVar) ?? ""))
{
logger.LogInformation("Update check prevented by env var {EnvVar}", PreventUpdateCheckEnvVar);
return;
}

if (!ShouldCheckForUpdate()) return;
var response = await ShouldUpdate();
if (!response.Update) return;

await ApplyUpdate(response.Release);
}

private async Task ApplyUpdate(FwLiteRelease latestRelease)
{
logger.LogInformation("New version available: {Version}", latestRelease.Version);
var packageManager = new PackageManager();
var asyncOperation = packageManager.AddPackageAsync(new Uri(latestRelease.Url), [], DeploymentOptions.None);
asyncOperation.Progress = (info, progressInfo) =>
{
logger.LogInformation("Downloading update: {ProgressPercentage}%", progressInfo.percentage);
};
var result = await asyncOperation.AsTask();
if (!string.IsNullOrEmpty(result.ErrorText))
{
logger.LogError(result.ExtendedErrorCode, "Failed to download update: {ErrorText}", result.ErrorText);
return;
}

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

private async Task<ShouldUpdateResponse> ShouldUpdate()
{
try
{
var response = await httpClientFactory
.CreateClient("Lexbox")
.SendAsync(new HttpRequestMessage(HttpMethod.Get, ShouldUpdateUrl)
{
Headers = { { "User-Agent", $"Fieldworks-Lite-Client/{AppVersion.Version}" } }
});
if (!response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
logger.LogError("Failed to get should update response lexbox: {StatusCode} {ResponseContent}",
response.StatusCode,
responseContent);
return new ShouldUpdateResponse(null);
}

return await response.Content.ReadFromJsonAsync<ShouldUpdateResponse>() ?? new ShouldUpdateResponse(null);
}
catch (Exception e)
{
if (connectivity.NetworkAccess == NetworkAccess.Internet)
{
logger.LogError(e, "Failed to fetch latest release");
}
else
{
logger.LogInformation(e, "Failed to fetch latest release, no internet connection");
}

return new ShouldUpdateResponse(null);
}
}

private bool ShouldCheckForUpdate()
{
if (ValidPositiveEnvVarValues.Contains(Environment.GetEnvironmentVariable(ForceUpdateCheckEnvVar) ?? ""))
return true;
var lastChecked = preferences.Get(LastUpdateCheck, DateTime.MinValue);
var timeSinceLastCheck = DateTime.UtcNow - lastChecked;
//if last checked is in the future (should never happen), then we want to reset the time and check again
if (timeSinceLastCheck.Hours < -1)
{
preferences.Set(LastUpdateCheck, DateTime.UtcNow);
return true;
}
if (timeSinceLastCheck.Hours < 20) return false;
preferences.Set(LastUpdateCheck, DateTime.UtcNow);
return true;
}
}
16 changes: 16 additions & 0 deletions backend/FwLite/FwLiteDesktop/Platforms/Windows/WindowsKernel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Runtime.InteropServices;

namespace FwLiteDesktop;

public static class WindowsKernel
{
public static void AddFwLiteWindows(this MauiAppBuilder builder)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
if (FwLiteDesktopKernel.IsPackagedApp)
{
builder.Services.AddSingleton<IMauiInitializeService, AppUpdateService>();
builder.Services.AddSingleton<IMauiInitializeService, WindowsShortcutService>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using Windows.ApplicationModel;

namespace FwLiteDesktop;

public class WindowsShortcutService(IVersionTracking versionTracking) : IMauiInitializeService
{
public void Initialize(IServiceProvider services)
{
if (!FwLiteDesktopKernel.IsPackagedApp || !versionTracking.IsFirstLaunchEver || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
var package = Package.Current;
IShellLink link = (IShellLink)new ShellLink();
link.SetPath($@"shell:AppsFolder\{package.Id.FamilyName}!App");
var file = (IPersistFile)link;
file.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "FieldWorks Lite.lnk"),
false);
}


[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
internal class ShellLink
{
}

[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
internal interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile,
int cchMaxPath,
out IntPtr pfd,
int fFlags);

void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);

void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath,
int cchIconPath,
out int piIcon);

void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
}
4 changes: 3 additions & 1 deletion backend/FwLite/FwLiteDesktop/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
"Run": {
"commandName": "Project",
"environmentVariables": {
"COREHOST_TRACE": "0"
"COREHOST_TRACE": "0",
"FWLITE_UPDATE_URL": "http://localhost:3000/api/fwlite-release/latest"
}
},
//won't work when WindowsPackageType is None, not sure how to make this work with both profiles without changing this
"Windows Machine": {
"commandName": "MsixPackage",
"nativeDebugging": false
Expand Down
67 changes: 67 additions & 0 deletions backend/LexBoxApi/Controllers/FwLiteReleaseController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using LexBoxApi.Otel;
using LexBoxApi.Services.FwLiteReleases;
using LexCore.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Hybrid;

namespace LexBoxApi.Controllers;

[ApiController]
[Route("/api/fwlite-release")]
[ApiExplorerSettings(GroupName = LexBoxKernel.OpenApiPublicDocumentName)]
public class FwLiteReleaseController(FwLiteReleaseService releaseService) : ControllerBase
{

[HttpGet("download-latest")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DownloadLatest()
{
using var activity = LexBoxActivitySource.Get().StartActivity();
var latestRelease = await releaseService.GetLatestRelease();
if (latestRelease is null)
{
activity?.SetStatus(ActivityStatusCode.Error, "Latest release not found");
return NotFound();
}
activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease.Version);
return Redirect(latestRelease.Url);
}

[HttpGet("latest")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesDefaultResponseType]
public async ValueTask<ActionResult<FwLiteRelease>> LatestRelease(string? appVersion = null)
{
using var activity = LexBoxActivitySource.Get().StartActivity();
activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion ?? "unknown");
var latestRelease = await releaseService.GetLatestRelease();
activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, latestRelease?.Version);
if (latestRelease is null) return NotFound();
return latestRelease;
}

[HttpGet("should-update")]
public async Task<ActionResult<ShouldUpdateResponse>> ShouldUpdate([FromQuery] string appVersion)
{
using var activity = LexBoxActivitySource.Get().StartActivity();
activity?.AddTag(FwLiteReleaseService.FwLiteClientVersionTag, appVersion);
var response = await releaseService.ShouldUpdate(appVersion);
activity?.AddTag(FwLiteReleaseService.FwLiteReleaseVersionTag, response.Release?.Version);
return response;
}

[HttpPost("new-release")]
[AllowAnonymous]
public async Task<OkResult> NewRelease()
{
await releaseService.InvalidateReleaseCache();
return Ok();
}
}
1 change: 1 addition & 0 deletions backend/LexBoxApi/LexBoxApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0-preview.9.24556.5" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.2.0" />
<PackageReference Include="Nito.AsyncEx.Coordination" Version="5.1.2" />
Expand Down
Loading

0 comments on commit ce28810

Please sign in to comment.