Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement auto update in Fieldworks Lite #1298

Merged
merged 19 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e5180c7
create api to redirect to the latest GitHub release of fw lite
hahn-kev Dec 2, 2024
146856b
invalidate lexbox release cache when a new version is released
hahn-kev Dec 2, 2024
2c8dfae
implement api to check for the latest version and queue an update
hahn-kev Dec 2, 2024
b36f9df
refactor TryUpdate and pull out apply update code
hahn-kev Dec 2, 2024
56da30b
pass app version and a user agent identifying fw lite
hahn-kev Dec 2, 2024
c8a2f56
validate the time since last update check and ignore it if it's in th…
hahn-kev Dec 2, 2024
ae6ee8c
add comment about launch profiles
hahn-kev Dec 3, 2024
88afbf5
ensure InformationalVersion is set
hahn-kev Dec 3, 2024
fa2e4a9
create a desktop shortcut on first launch
hahn-kev Dec 3, 2024
64fb903
correct release invalidation url
hahn-kev Dec 3, 2024
43355b1
move Windows-specific code into the Windows platform folder
hahn-kev Dec 3, 2024
02e6313
fix space prefixed on header value
hahn-kev Dec 3, 2024
e01f0c0
refactor update checking code to delegate the update decision to the …
hahn-kev Dec 3, 2024
8bba522
break out update logic into its own service to call from the release …
hahn-kev Dec 4, 2024
87efa3d
write tests for release service
hahn-kev Dec 4, 2024
dd498f7
increase timeout on fw lite build and test job
hahn-kev Dec 4, 2024
224388d
make app name consistent
hahn-kev Dec 9, 2024
05fbec0
don't reference the install path when making shortcut since it will c…
hahn-kev Dec 9, 2024
da8ba2c
add a default value for GetLatestRelease cancellation token
hahn-kev Dec 9, 2024
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
7 changes: 6 additions & 1 deletion .github/workflows/fw-lite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ on:
jobs:
build-and-test:
name: Build FW Lite and run tests
timeout-minutes: 20
timeout-minutes: 30
runs-on: windows-latest
outputs:
version: ${{ steps.setVersion.outputs.VERSION }}
Expand Down 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;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm fine merging this, but I think it deserves more thought.

Checking for updates is cheap.
Installing updates is not.

So, we're not really protecting from checking for updates, but maybe repeated failed updates?

If we release two updates within 24hrs then we probably have good reason to.
Maybe the logic should be more like:

  • Always look for updates on start
  • Don't try the same update more than once within 24hrs (unless it clearly failed due to connectivity problems?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You're right is is cheap to check. At this point I have no idea what a failed update would look like or how that would even happen considering we're not running any custom code during install. Not that I doubt it could happen. That said, tracking update attempts introduces complexity which I'd rather avoid, so maybe let's leave it as is for now and see what we learn with people actually using it and updating to new versions

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();
}
}
Loading
Loading