Skip to content

Commit 46849a5

Browse files
authored
feat: add auto updater (#117)
1 parent 56003ed commit 46849a5

38 files changed

+2960
-65
lines changed

.github/workflows/release.yaml

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
6969
token_format: "access_token"
7070

71+
- name: Install gcloud
72+
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4
73+
7174
- name: Install wix
7275
shell: pwsh
7376
run: |
@@ -120,6 +123,51 @@ jobs:
120123
env:
121124
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122125

126+
- name: Update appcast
127+
if: startsWith(github.ref, 'refs/tags/')
128+
shell: pwsh
129+
run: |
130+
$ErrorActionPreference = "Stop"
131+
132+
# The Update-AppCast.ps1 script fetches the release notes from GitHub,
133+
# which might take a few seconds to be ready.
134+
Start-Sleep -Seconds 10
135+
136+
# Save the appcast signing key to a temporary file.
137+
$keyPath = Join-Path $env:TEMP "appcast-key.pem"
138+
$key = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($env:APPCAST_SIGNATURE_KEY_BASE64))
139+
Set-Content -Path $keyPath -Value $key
140+
141+
# Download the old appcast from GCS.
142+
$oldAppCastPath = Join-Path $env:TEMP "appcast.old.xml"
143+
& gsutil cp $env:APPCAST_GCS_URI $oldAppCastPath
144+
if ($LASTEXITCODE -ne 0) { throw "Failed to download appcast" }
145+
146+
# Generate the new appcast and signature.
147+
$newAppCastPath = Join-Path $env:TEMP "appcast.new.xml"
148+
$newAppCastSignaturePath = $newAppCastPath + ".signature"
149+
& ./scripts/Update-AppCast.ps1 `
150+
-tag "${{ github.ref_name }}" `
151+
-channel stable `
152+
-x64Path "${{ steps.release.outputs.X64_OUTPUT_PATH }}" `
153+
-arm64Path "${{ steps.release.outputs.ARM64_OUTPUT_PATH }}" `
154+
-keyPath $keyPath `
155+
-inputAppCastPath $oldAppCastPath `
156+
-outputAppCastPath $newAppCastPath `
157+
-outputAppCastSignaturePath $newAppCastSignaturePath
158+
if ($LASTEXITCODE -ne 0) { throw "Failed to generate new appcast" }
159+
160+
# Upload the new appcast and signature to GCS.
161+
& gsutil -h "Cache-Control:no-cache,max-age=0" cp $newAppCastPath $env:APPCAST_GCS_URI
162+
if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast" }
163+
& gsutil -h "Cache-Control:no-cache,max-age=0" cp $newAppCastSignaturePath $env:APPCAST_SIGNATURE_GCS_URI
164+
if ($LASTEXITCODE -ne 0) { throw "Failed to upload new appcast signature" }
165+
env:
166+
APPCAST_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml
167+
APPCAST_SIGNATURE_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml.signature
168+
APPCAST_SIGNATURE_KEY_BASE64: ${{ secrets.APPCAST_SIGNATURE_KEY_BASE64 }}
169+
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
170+
123171
winget:
124172
runs-on: depot-windows-latest
125173
needs: release
@@ -177,7 +225,6 @@ jobs:
177225
# to GitHub and then making a PR in a different repo.
178226
WINGET_GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
179227

180-
181228
- name: Comment on PR
182229
run: |
183230
# wait 30 seconds

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,12 @@ publish
411411
*.wixmdb
412412
*.wixprj
413413
*.wixproj
414+
415+
appcast.xml
416+
appcast.xml.signature
417+
*.key
418+
*.key.*
419+
*.pem
420+
*.pem.*
421+
*.pub
422+
*.pub.*

App/App.csproj

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
<DefineConstants>DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION</DefineConstants>
2020

2121
<AssemblyName>Coder Desktop</AssemblyName>
22+
<AssemblyTitle>Coder Desktop</AssemblyTitle>
23+
<Company>Coder Technologies Inc.</Company>
24+
<Product>Coder Desktop</Product>
25+
<Copyright>© Coder Technologies Inc.</Copyright>
2226
<ApplicationIcon>coder.ico</ApplicationIcon>
2327
</PropertyGroup>
2428

@@ -31,9 +35,7 @@
3135

3236
<ItemGroup>
3337
<Content Include="coder.ico" />
34-
</ItemGroup>
35-
36-
<ItemGroup>
38+
<EmbeddedResource Include="Assets\changelog.css" />
3739
<Manifest Include="$(ApplicationManifest)" />
3840
</ItemGroup>
3941

@@ -68,12 +70,17 @@
6870
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
6971
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.4" />
7072
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
73+
<PackageReference Include="NetSparkleUpdater.SparkleUpdater" Version="3.0.2" />
7174
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
7275
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
7376
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
7477
<PackageReference Include="WinUIEx" Version="2.5.1" />
7578
</ItemGroup>
7679

80+
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
81+
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
82+
</ItemGroup>
83+
7784
<ItemGroup>
7885
<ProjectReference Include="..\CoderSdk\CoderSdk.csproj" />
7986
<ProjectReference Include="..\MutagenSdk\MutagenSdk.csproj" />

App/App.xaml.cs

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,46 @@
1616
using Microsoft.Extensions.DependencyInjection;
1717
using Microsoft.Extensions.Hosting;
1818
using Microsoft.Extensions.Logging;
19+
using Microsoft.UI.Dispatching;
1920
using Microsoft.UI.Xaml;
2021
using Microsoft.Win32;
2122
using Microsoft.Windows.AppLifecycle;
2223
using Microsoft.Windows.AppNotifications;
24+
using NetSparkleUpdater.Interfaces;
2325
using Serilog;
2426
using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
2527

2628
namespace Coder.Desktop.App;
2729

28-
public partial class App : Application
30+
public partial class App : Application, IDispatcherQueueManager
2931
{
30-
private readonly IServiceProvider _services;
31-
32-
private bool _handleWindowClosed = true;
3332
private const string MutagenControllerConfigSection = "MutagenController";
33+
private const string UpdaterConfigSection = "Updater";
3434

3535
#if !DEBUG
3636
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App";
37-
private const string logFilename = "app.log";
37+
private const string LogFilename = "app.log";
38+
private const string DefaultLogLevel = "Information";
3839
#else
3940
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp";
40-
private const string logFilename = "debug-app.log";
41+
private const string LogFilename = "debug-app.log";
42+
private const string DefaultLogLevel = "Debug";
4143
#endif
4244

45+
// HACK: This is exposed for dispatcher queue access. The notifier uses
46+
// this to ensure action callbacks run in the UI thread (as
47+
// activation events aren't in the main thread).
48+
public TrayWindow? TrayWindow;
49+
50+
private readonly IServiceProvider _services;
4351
private readonly ILogger<App> _logger;
4452
private readonly IUriHandler _uriHandler;
45-
53+
private readonly IUserNotifier _userNotifier;
4654
private readonly ISettingsManager<CoderConnectSettings> _settingsManager;
47-
4855
private readonly IHostApplicationLifetime _appLifetime;
4956

57+
private bool _handleWindowClosed = true;
58+
5059
public App()
5160
{
5261
var builder = Host.CreateApplicationBuilder();
@@ -58,7 +67,17 @@ public App()
5867
configBuilder.Add(
5968
new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey));
6069
configBuilder.Add(
61-
new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey));
70+
new RegistryConfigurationSource(
71+
Registry.CurrentUser,
72+
ConfigSubKey,
73+
// Block "Updater:" configuration from HKCU, so that updater
74+
// settings can only be set at the HKLM level.
75+
//
76+
// HACK: This isn't super robust, but the security risk is
77+
// minor anyway. Malicious apps running as the user could
78+
// likely override this setting by altering the memory of
79+
// this app.
80+
UpdaterConfigSection + ":"));
6281

6382
var services = builder.Services;
6483

@@ -71,6 +90,7 @@ public App()
7190
services.AddSingleton<ICoderApiClientFactory, CoderApiClientFactory>();
7291
services.AddSingleton<IAgentApiClientFactory, AgentApiClientFactory>();
7392

93+
services.AddSingleton<IDispatcherQueueManager>(_ => this);
7494
services.AddSingleton<ICredentialBackend>(_ =>
7595
new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName));
7696
services.AddSingleton<ICredentialManager, CredentialManager>();
@@ -84,6 +104,12 @@ public App()
84104
services.AddSingleton<IRdpConnector, RdpConnector>();
85105
services.AddSingleton<IUriHandler, UriHandler>();
86106

107+
services.AddOptions<UpdaterConfig>()
108+
.Bind(builder.Configuration.GetSection(UpdaterConfigSection));
109+
services.AddSingleton<IUpdaterUpdateAvailableViewModelFactory, UpdaterUpdateAvailableViewModelFactory>();
110+
services.AddSingleton<IUIFactory, CoderSparkleUIFactory>();
111+
services.AddSingleton<IUpdateController, SparkleUpdateController>();
112+
87113
// SignInWindow views and view models
88114
services.AddTransient<SignInViewModel>();
89115
services.AddTransient<SignInWindow>();
@@ -119,6 +145,7 @@ public App()
119145
_services = services.BuildServiceProvider();
120146
_logger = _services.GetRequiredService<ILogger<App>>();
121147
_uriHandler = _services.GetRequiredService<IUriHandler>();
148+
_userNotifier = _services.GetRequiredService<IUserNotifier>();
122149
_settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
123150
_appLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
124151

@@ -142,16 +169,18 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
142169
{
143170
_logger.LogInformation("new instance launched");
144171

145-
_ = InitializeServicesAsync(_appLifetime.ApplicationStopping);
146-
147172
// Prevent the TrayWindow from closing, just hide it.
148-
var trayWindow = _services.GetRequiredService<TrayWindow>();
149-
trayWindow.Closed += (_, closedArgs) =>
173+
if (TrayWindow != null)
174+
throw new InvalidOperationException("OnLaunched was called multiple times? TrayWindow is already set");
175+
TrayWindow = _services.GetRequiredService<TrayWindow>();
176+
TrayWindow.Closed += (_, closedArgs) =>
150177
{
151178
if (!_handleWindowClosed) return;
152179
closedArgs.Handled = true;
153-
trayWindow.AppWindow.Hide();
180+
TrayWindow.AppWindow.Hide();
154181
};
182+
183+
_ = InitializeServicesAsync(_appLifetime.ApplicationStopping);
155184
}
156185

157186
/// <summary>
@@ -261,27 +290,49 @@ public void OnActivated(object? sender, AppActivationArguments args)
261290

262291
public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args)
263292
{
264-
// right now, we don't do anything other than log
265-
_logger.LogInformation("handled notification activation");
293+
_logger.LogInformation("handled notification activation: {Argument}", args.Argument);
294+
_userNotifier.HandleNotificationActivation(args.Arguments);
266295
}
267296

268297
private static void AddDefaultConfig(IConfigurationBuilder builder)
269298
{
270299
var logPath = Path.Combine(
271300
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
272301
"CoderDesktop",
273-
logFilename);
302+
LogFilename);
274303
builder.AddInMemoryCollection(new Dictionary<string, string?>
275304
{
276305
[MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe",
306+
277307
["Serilog:Using:0"] = "Serilog.Sinks.File",
278-
["Serilog:MinimumLevel"] = "Information",
308+
["Serilog:MinimumLevel"] = DefaultLogLevel,
279309
["Serilog:Enrich:0"] = "FromLogContext",
280310
["Serilog:WriteTo:0:Name"] = "File",
281311
["Serilog:WriteTo:0:Args:path"] = logPath,
282312
["Serilog:WriteTo:0:Args:outputTemplate"] =
283313
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}",
284314
["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
315+
316+
#if DEBUG
317+
["Serilog:Using:1"] = "Serilog.Sinks.Debug",
318+
["Serilog:Enrich:1"] = "FromLogContext",
319+
["Serilog:WriteTo:1:Name"] = "Debug",
320+
["Serilog:WriteTo:1:Args:outputTemplate"] =
321+
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}",
322+
#endif
285323
});
286324
}
325+
326+
public void RunInUiThread(DispatcherQueueHandler action)
327+
{
328+
var dispatcherQueue = TrayWindow?.DispatcherQueue;
329+
if (dispatcherQueue is null)
330+
throw new InvalidOperationException("DispatcherQueue is not available");
331+
if (dispatcherQueue.HasThreadAccess)
332+
{
333+
action();
334+
return;
335+
}
336+
dispatcherQueue.TryEnqueue(action);
337+
}
287338
}

0 commit comments

Comments
 (0)