Skip to content

Commit 4bba070

Browse files
committed
Merge branch 'main' into dean/updater
2 parents 7d35551 + 059179c commit 4bba070

25 files changed

+1121
-114
lines changed

App/App.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
<ItemGroup>
6060
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
6161
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
62+
<PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.250402" />
6263
<PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
6364
<PackageReference Include="DependencyPropertyGenerator" Version="1.5.0">
6465
<PrivateAssets>all</PrivateAssets>

App/App.xaml.cs

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Diagnostics;
43
using System.IO;
54
using System.Threading;
65
using System.Threading.Tasks;
@@ -54,6 +53,10 @@ public partial class App : Application
5453

5554
private bool _handleWindowClosed = true;
5655

56+
private readonly ISettingsManager<CoderConnectSettings> _settingsManager;
57+
58+
private readonly IHostApplicationLifetime _appLifetime;
59+
5760
public App()
5861
{
5962
var builder = Host.CreateApplicationBuilder();
@@ -116,6 +119,13 @@ public App()
116119
// FileSyncListMainPage is created by FileSyncListWindow.
117120
services.AddTransient<FileSyncListWindow>();
118121

122+
services.AddSingleton<ISettingsManager<CoderConnectSettings>, SettingsManager<CoderConnectSettings>>();
123+
services.AddSingleton<IStartupManager, StartupManager>();
124+
// SettingsWindow views and view models
125+
services.AddTransient<SettingsViewModel>();
126+
// SettingsMainPage is created by SettingsWindow.
127+
services.AddTransient<SettingsWindow>();
128+
119129
// DirectoryPickerWindow views and view models are created by FileSyncListViewModel.
120130

121131
// TrayWindow views and view models
@@ -136,6 +146,8 @@ public App()
136146
_logger = _services.GetRequiredService<ILogger<App>>();
137147
_uriHandler = _services.GetRequiredService<IUriHandler>();
138148
_userNotifier = _services.GetRequiredService<IUserNotifier>();
149+
_settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
150+
_appLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
139151

140152
InitializeComponent();
141153
}
@@ -168,57 +180,75 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
168180
TrayWindow.AppWindow.Hide();
169181
};
170182

171-
// Start connecting to the manager in the background.
183+
_ = InitializeServicesAsync(_appLifetime.ApplicationStopping);
184+
}
185+
186+
/// <summary>
187+
/// Loads stored VPN credentials, reconnects the RPC controller,
188+
/// and (optionally) starts the VPN tunnel on application launch.
189+
/// </summary>
190+
private async Task InitializeServicesAsync(CancellationToken cancellationToken = default)
191+
{
192+
var credentialManager = _services.GetRequiredService<ICredentialManager>();
172193
var rpcController = _services.GetRequiredService<IRpcController>();
173-
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
174-
// Passing in a CT with no cancellation is desired here, because
175-
// the named pipe open will block until the pipe comes up.
176-
_logger.LogDebug("reconnecting with VPN service");
177-
_ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
194+
195+
using var credsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
196+
credsCts.CancelAfter(TimeSpan.FromSeconds(15));
197+
198+
var loadCredsTask = credentialManager.LoadCredentials(credsCts.Token);
199+
var reconnectTask = rpcController.Reconnect(cancellationToken);
200+
var settingsTask = _settingsManager.Read(cancellationToken);
201+
202+
var dependenciesLoaded = true;
203+
204+
try
178205
{
179-
if (t.Exception != null)
180-
{
181-
_logger.LogError(t.Exception, "failed to connect to VPN service");
182-
#if DEBUG
183-
Debug.WriteLine(t.Exception);
184-
Debugger.Break();
185-
#endif
186-
}
187-
});
206+
await Task.WhenAll(loadCredsTask, reconnectTask, settingsTask);
207+
}
208+
catch (Exception)
209+
{
210+
if (loadCredsTask.IsFaulted)
211+
_logger.LogError(loadCredsTask.Exception!.GetBaseException(),
212+
"Failed to load credentials");
188213

189-
// Load the credentials in the background.
190-
var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
191-
var credentialManager = _services.GetRequiredService<ICredentialManager>();
192-
_ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
214+
if (reconnectTask.IsFaulted)
215+
_logger.LogError(reconnectTask.Exception!.GetBaseException(),
216+
"Failed to connect to VPN service");
217+
218+
if (settingsTask.IsFaulted)
219+
_logger.LogError(settingsTask.Exception!.GetBaseException(),
220+
"Failed to fetch Coder Connect settings");
221+
222+
// Don't attempt to connect if we failed to load credentials or reconnect.
223+
// This will prevent the app from trying to connect to the VPN service.
224+
dependenciesLoaded = false;
225+
}
226+
227+
var attemptCoderConnection = settingsTask.Result?.ConnectOnLaunch ?? false;
228+
if (dependenciesLoaded && attemptCoderConnection)
193229
{
194-
if (t.Exception != null)
230+
try
195231
{
196-
_logger.LogError(t.Exception, "failed to load credentials");
197-
#if DEBUG
198-
Debug.WriteLine(t.Exception);
199-
Debugger.Break();
200-
#endif
232+
await rpcController.StartVpn(cancellationToken);
201233
}
202-
203-
credentialManagerCts.Dispose();
204-
}, CancellationToken.None);
234+
catch (Exception ex)
235+
{
236+
_logger.LogError(ex, "Failed to connect on launch");
237+
}
238+
}
205239

206240
// Initialize file sync.
207-
// We're adding a 5s delay here to avoid race conditions when loading the mutagen binary.
208-
_ = Task.Delay(5000).ContinueWith((_) =>
241+
using var syncSessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
242+
syncSessionCts.CancelAfter(TimeSpan.FromSeconds(10));
243+
var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
244+
try
209245
{
210-
var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
211-
var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
212-
syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(
213-
t =>
214-
{
215-
if (t.IsCanceled || t.Exception != null)
216-
{
217-
_logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
218-
}
219-
syncSessionCts.Dispose();
220-
}, CancellationToken.None);
221-
});
246+
await syncSessionController.RefreshState(syncSessionCts.Token);
247+
}
248+
catch (Exception ex)
249+
{
250+
_logger.LogError($"Failed to refresh sync session state {ex.Message}", ex);
251+
}
222252
}
223253

224254
public void OnActivated(object? sender, AppActivationArguments args)

App/Models/RpcModel.cs

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using Coder.Desktop.App.Converters;
25
using Coder.Desktop.Vpn.Proto;
36

47
namespace Coder.Desktop.App.Models;
@@ -19,11 +22,168 @@ public enum VpnLifecycle
1922
Stopping,
2023
}
2124

25+
public enum VpnStartupStage
26+
{
27+
Unknown,
28+
Initializing,
29+
Downloading,
30+
Finalizing,
31+
}
32+
33+
public class VpnDownloadProgress
34+
{
35+
public ulong BytesWritten { get; set; } = 0;
36+
public ulong? BytesTotal { get; set; } = null; // null means unknown total size
37+
38+
public double Progress
39+
{
40+
get
41+
{
42+
if (BytesTotal is > 0)
43+
{
44+
return (double)BytesWritten / BytesTotal.Value;
45+
}
46+
return 0.0;
47+
}
48+
}
49+
50+
public override string ToString()
51+
{
52+
// TODO: it would be nice if the two suffixes could match
53+
var s = FriendlyByteConverter.FriendlyBytes(BytesWritten);
54+
if (BytesTotal != null)
55+
s += $" of {FriendlyByteConverter.FriendlyBytes(BytesTotal.Value)}";
56+
else
57+
s += " of unknown";
58+
if (BytesTotal != null)
59+
s += $" ({Progress:0%})";
60+
return s;
61+
}
62+
63+
public VpnDownloadProgress Clone()
64+
{
65+
return new VpnDownloadProgress
66+
{
67+
BytesWritten = BytesWritten,
68+
BytesTotal = BytesTotal,
69+
};
70+
}
71+
72+
public static VpnDownloadProgress FromProto(StartProgressDownloadProgress proto)
73+
{
74+
return new VpnDownloadProgress
75+
{
76+
BytesWritten = proto.BytesWritten,
77+
BytesTotal = proto.HasBytesTotal ? proto.BytesTotal : null,
78+
};
79+
}
80+
}
81+
82+
public class VpnStartupProgress
83+
{
84+
public const string DefaultStartProgressMessage = "Starting Coder Connect...";
85+
86+
// Scale the download progress to an overall progress value between these
87+
// numbers.
88+
private const double DownloadProgressMin = 0.05;
89+
private const double DownloadProgressMax = 0.80;
90+
91+
public VpnStartupStage Stage { get; init; } = VpnStartupStage.Unknown;
92+
public VpnDownloadProgress? DownloadProgress { get; init; } = null;
93+
94+
// 0.0 to 1.0
95+
public double Progress
96+
{
97+
get
98+
{
99+
switch (Stage)
100+
{
101+
case VpnStartupStage.Unknown:
102+
case VpnStartupStage.Initializing:
103+
return 0.0;
104+
case VpnStartupStage.Downloading:
105+
var progress = DownloadProgress?.Progress ?? 0.0;
106+
return DownloadProgressMin + (DownloadProgressMax - DownloadProgressMin) * progress;
107+
case VpnStartupStage.Finalizing:
108+
return DownloadProgressMax;
109+
default:
110+
throw new ArgumentOutOfRangeException();
111+
}
112+
}
113+
}
114+
115+
public override string ToString()
116+
{
117+
switch (Stage)
118+
{
119+
case VpnStartupStage.Unknown:
120+
case VpnStartupStage.Initializing:
121+
return DefaultStartProgressMessage;
122+
case VpnStartupStage.Downloading:
123+
var s = "Downloading Coder Connect binary...";
124+
if (DownloadProgress is not null)
125+
{
126+
s += "\n" + DownloadProgress;
127+
}
128+
129+
return s;
130+
case VpnStartupStage.Finalizing:
131+
return "Finalizing Coder Connect startup...";
132+
default:
133+
throw new ArgumentOutOfRangeException();
134+
}
135+
}
136+
137+
public VpnStartupProgress Clone()
138+
{
139+
return new VpnStartupProgress
140+
{
141+
Stage = Stage,
142+
DownloadProgress = DownloadProgress?.Clone(),
143+
};
144+
}
145+
146+
public static VpnStartupProgress FromProto(StartProgress proto)
147+
{
148+
return new VpnStartupProgress
149+
{
150+
Stage = proto.Stage switch
151+
{
152+
StartProgressStage.Initializing => VpnStartupStage.Initializing,
153+
StartProgressStage.Downloading => VpnStartupStage.Downloading,
154+
StartProgressStage.Finalizing => VpnStartupStage.Finalizing,
155+
_ => VpnStartupStage.Unknown,
156+
},
157+
DownloadProgress = proto.Stage is StartProgressStage.Downloading ?
158+
VpnDownloadProgress.FromProto(proto.DownloadProgress) :
159+
null,
160+
};
161+
}
162+
}
163+
22164
public class RpcModel
23165
{
24166
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
25167

26-
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
168+
public VpnLifecycle VpnLifecycle
169+
{
170+
get;
171+
set
172+
{
173+
if (VpnLifecycle != value && value == VpnLifecycle.Starting)
174+
// Reset the startup progress when the VPN lifecycle changes to
175+
// Starting.
176+
VpnStartupProgress = null;
177+
field = value;
178+
}
179+
}
180+
181+
// Nullable because it is only set when the VpnLifecycle is Starting
182+
public VpnStartupProgress? VpnStartupProgress
183+
{
184+
get => VpnLifecycle is VpnLifecycle.Starting ? field ?? new VpnStartupProgress() : null;
185+
set;
186+
}
27187

28188
public IReadOnlyList<Workspace> Workspaces { get; set; } = [];
29189

@@ -35,6 +195,7 @@ public RpcModel Clone()
35195
{
36196
RpcLifecycle = RpcLifecycle,
37197
VpnLifecycle = VpnLifecycle,
198+
VpnStartupProgress = VpnStartupProgress?.Clone(),
38199
Workspaces = Workspaces,
39200
Agents = Agents,
40201
};

0 commit comments

Comments
 (0)