Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a6f7bb6

Browse files
authoredMay 12, 2025··
feat: add workspace app icons to tray window (#86)
Closes #50
1 parent 9e4ebf2 commit a6f7bb6

38 files changed

+2057
-242
lines changed
 

‎App/App.csproj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<OutputType>WinExe</OutputType>
44
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
@@ -16,7 +16,7 @@
1616
<!-- To use CommunityToolkit.Mvvm.ComponentModel.ObservablePropertyAttribute: -->
1717
<LangVersion>preview</LangVersion>
1818
<!-- We have our own implementation of main with exception handling -->
19-
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
19+
<DefineConstants>DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION</DefineConstants>
2020

2121
<AssemblyName>Coder Desktop</AssemblyName>
2222
<ApplicationIcon>coder.ico</ApplicationIcon>
@@ -57,6 +57,7 @@
5757
<ItemGroup>
5858
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
5959
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
60+
<PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
6061
<PackageReference Include="DependencyPropertyGenerator" Version="1.5.0">
6162
<PrivateAssets>all</PrivateAssets>
6263
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

‎App/App.xaml.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Coder.Desktop.App.Views;
1212
using Coder.Desktop.App.Views.Pages;
1313
using Coder.Desktop.CoderSdk.Agent;
14+
using Coder.Desktop.CoderSdk.Coder;
1415
using Coder.Desktop.Vpn;
1516
using Microsoft.Extensions.Configuration;
1617
using Microsoft.Extensions.DependencyInjection;
@@ -19,9 +20,9 @@
1920
using Microsoft.UI.Xaml;
2021
using Microsoft.Win32;
2122
using Microsoft.Windows.AppLifecycle;
23+
using Microsoft.Windows.AppNotifications;
2224
using Serilog;
2325
using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
24-
using Microsoft.Windows.AppNotifications;
2526

2627
namespace Coder.Desktop.App;
2728

@@ -64,8 +65,11 @@ public App()
6465
loggerConfig.ReadFrom.Configuration(builder.Configuration);
6566
});
6667

68+
services.AddSingleton<ICoderApiClientFactory, CoderApiClientFactory>();
6769
services.AddSingleton<IAgentApiClientFactory, AgentApiClientFactory>();
6870

71+
services.AddSingleton<ICredentialBackend>(_ =>
72+
new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName));
6973
services.AddSingleton<ICredentialManager, CredentialManager>();
7074
services.AddSingleton<IRpcController, RpcController>();
7175

@@ -95,6 +99,8 @@ public App()
9599
services.AddTransient<TrayWindowLoginRequiredPage>();
96100
services.AddTransient<TrayWindowLoginRequiredViewModel>();
97101
services.AddTransient<TrayWindowLoginRequiredPage>();
102+
services.AddSingleton<IAgentAppViewModelFactory, AgentAppViewModelFactory>();
103+
services.AddSingleton<IAgentViewModelFactory, AgentViewModelFactory>();
98104
services.AddTransient<TrayWindowViewModel>();
99105
services.AddTransient<TrayWindowMainPage>();
100106
services.AddTransient<TrayWindow>();

‎App/Controls/ExpandChevron.xaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<UserControl
4+
x:Class="Coder.Desktop.App.Controls.ExpandChevron"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
10+
mc:Ignorable="d">
11+
12+
<Grid>
13+
<AnimatedIcon
14+
Grid.Column="0"
15+
x:Name="ChevronIcon"
16+
Width="16"
17+
Height="16"
18+
Margin="0,0,8,0"
19+
RenderTransformOrigin="0.5, 0.5"
20+
Foreground="{x:Bind Foreground, Mode=OneWay}"
21+
HorizontalAlignment="Center"
22+
VerticalAlignment="Center"
23+
AnimatedIcon.State="NormalOff">
24+
25+
<animatedVisuals:AnimatedChevronRightDownSmallVisualSource />
26+
<AnimatedIcon.FallbackIconSource>
27+
<FontIconSource Glyph="&#xE76C;" />
28+
</AnimatedIcon.FallbackIconSource>
29+
</AnimatedIcon>
30+
</Grid>
31+
</UserControl>

‎App/Controls/ExpandChevron.xaml.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using DependencyPropertyGenerator;
2+
using Microsoft.UI.Xaml.Controls;
3+
4+
namespace Coder.Desktop.App.Controls;
5+
6+
[DependencyProperty<bool>("IsOpen", DefaultValue = false)]
7+
public sealed partial class ExpandChevron : UserControl
8+
{
9+
public ExpandChevron()
10+
{
11+
InitializeComponent();
12+
}
13+
14+
partial void OnIsOpenChanged(bool oldValue, bool newValue)
15+
{
16+
var newState = newValue ? "NormalOn" : "NormalOff";
17+
AnimatedIcon.SetState(ChevronIcon, newState);
18+
}
19+
}

‎App/Controls/ExpandContent.xaml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<UserControl
4+
x:Class="Coder.Desktop.App.Controls.ExpandContent"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:toolkit="using:CommunityToolkit.WinUI"
10+
mc:Ignorable="d">
11+
12+
<Grid x:Name="CollapsiblePanel" Opacity="0" Visibility="Collapsed" toolkit:UIElementExtensions.ClipToBounds="True">
13+
<Grid.RenderTransform>
14+
<TranslateTransform x:Name="SlideTransform" Y="-10" />
15+
</Grid.RenderTransform>
16+
17+
<VisualStateManager.VisualStateGroups>
18+
<VisualStateGroup>
19+
<VisualState x:Name="ExpandedState">
20+
<Storyboard>
21+
<DoubleAnimation
22+
Storyboard.TargetName="CollapsiblePanel"
23+
Storyboard.TargetProperty="Opacity"
24+
To="1"
25+
Duration="0:0:0.2" />
26+
<DoubleAnimation
27+
Storyboard.TargetName="SlideTransform"
28+
Storyboard.TargetProperty="Y"
29+
To="0"
30+
Duration="0:0:0.2" />
31+
</Storyboard>
32+
</VisualState>
33+
34+
<VisualState x:Name="CollapsedState">
35+
<Storyboard Completed="{x:Bind CollapseAnimation_Completed}">
36+
<DoubleAnimation
37+
Storyboard.TargetName="CollapsiblePanel"
38+
Storyboard.TargetProperty="Opacity"
39+
To="0"
40+
Duration="0:0:0.2" />
41+
<DoubleAnimation
42+
Storyboard.TargetName="SlideTransform"
43+
Storyboard.TargetProperty="Y"
44+
To="-10"
45+
Duration="0:0:0.2" />
46+
</Storyboard>
47+
</VisualState>
48+
</VisualStateGroup>
49+
</VisualStateManager.VisualStateGroups>
50+
</Grid>
51+
</UserControl>

‎App/Controls/ExpandContent.xaml.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using DependencyPropertyGenerator;
2+
using Microsoft.UI.Xaml;
3+
using Microsoft.UI.Xaml.Controls;
4+
using Microsoft.UI.Xaml.Markup;
5+
6+
namespace Coder.Desktop.App.Controls;
7+
8+
[ContentProperty(Name = nameof(Children))]
9+
[DependencyProperty<bool>("IsOpen", DefaultValue = false)]
10+
public sealed partial class ExpandContent : UserControl
11+
{
12+
public UIElementCollection Children => CollapsiblePanel.Children;
13+
14+
public ExpandContent()
15+
{
16+
InitializeComponent();
17+
}
18+
19+
public void CollapseAnimation_Completed(object? sender, object args)
20+
{
21+
// Hide the panel completely when the collapse animation is done. This
22+
// cannot be done with keyframes for some reason.
23+
//
24+
// Without this, the space will still be reserved for the panel.
25+
CollapsiblePanel.Visibility = Visibility.Collapsed;
26+
}
27+
28+
partial void OnIsOpenChanged(bool oldValue, bool newValue)
29+
{
30+
var newState = newValue ? "ExpandedState" : "CollapsedState";
31+
32+
// The animation can't set visibility when starting or ending the
33+
// animation.
34+
if (newValue)
35+
CollapsiblePanel.Visibility = Visibility.Visible;
36+
37+
VisualStateManager.GoToState(this, newState, true);
38+
}
39+
}

‎App/Converters/DependencyObjectSelector.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ private void UpdateSelectedObject()
156156
ClearValue(SelectedObjectProperty);
157157
}
158158

159+
private static void VerifyReferencesProperty(IObservableVector<DependencyObject> references)
160+
{
161+
// Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>.
162+
var items = references.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
163+
var keys = items.Select(i => i.Key).Distinct().ToArray();
164+
if (keys.Length != references.Count)
165+
throw new ArgumentException("ObservableCollection Keys must be unique.");
166+
}
167+
159168
// Called when the References property is replaced.
160169
private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
161170
{
@@ -166,12 +175,16 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr
166175
oldValue.VectorChanged -= self.OnVectorChangedReferences;
167176
var newValue = args.NewValue as DependencyObjectCollection;
168177
if (newValue != null)
178+
{
179+
VerifyReferencesProperty(newValue);
169180
newValue.VectorChanged += self.OnVectorChangedReferences;
181+
}
170182
}
171183

172184
// Called when the References collection changes without being replaced.
173185
private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
174186
{
187+
VerifyReferencesProperty(sender);
175188
UpdateSelectedObject();
176189
}
177190

‎App/Models/CredentialModel.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1+
using System;
2+
13
namespace Coder.Desktop.App.Models;
24

35
public enum CredentialState
46
{
57
// Unknown means "we haven't checked yet"
68
Unknown,
79

8-
// Invalid means "we checked and there's either no saved credentials or they are not valid"
10+
// Invalid means "we checked and there's either no saved credentials, or they are not valid"
911
Invalid,
1012

11-
// Valid means "we checked and there are saved credentials and they are valid"
13+
// Valid means "we checked and there are saved credentials, and they are valid"
1214
Valid,
1315
}
1416

1517
public class CredentialModel
1618
{
1719
public CredentialState State { get; init; } = CredentialState.Unknown;
1820

19-
public string? CoderUrl { get; init; }
21+
public Uri? CoderUrl { get; init; }
2022
public string? ApiToken { get; init; }
2123

2224
public string? Username { get; init; }

‎App/Program.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,9 @@ private static void Main(string[] args)
6363
notificationManager.NotificationInvoked += app.HandleNotification;
6464
notificationManager.Register();
6565
if (activationArgs.Kind != ExtendedActivationKind.Launch)
66-
{
6766
// this means we were activated without having already launched, so handle
6867
// the activation as well.
6968
app.OnActivated(null, activationArgs);
70-
}
7169
});
7270
}
7371
catch (Exception e)

‎App/Services/CredentialManager.cs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class RawCredentials
2121
[JsonSerializable(typeof(RawCredentials))]
2222
public partial class RawCredentialsJsonContext : JsonSerializerContext;
2323

24-
public interface ICredentialManager
24+
public interface ICredentialManager : ICoderApiClientCredentialProvider
2525
{
2626
public event EventHandler<CredentialModel> CredentialsChanged;
2727

@@ -59,7 +59,8 @@ public interface ICredentialBackend
5959
/// </summary>
6060
public class CredentialManager : ICredentialManager
6161
{
62-
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
62+
private readonly ICredentialBackend Backend;
63+
private readonly ICoderApiClientFactory CoderApiClientFactory;
6364

6465
// _opLock is held for the full duration of SetCredentials, and partially
6566
// during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and
@@ -79,14 +80,6 @@ public class CredentialManager : ICredentialManager
7980
// immediate).
8081
private volatile CredentialModel? _latestCredentials;
8182

82-
private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName);
83-
84-
private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory();
85-
86-
public CredentialManager()
87-
{
88-
}
89-
9083
public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory)
9184
{
9285
Backend = backend;
@@ -108,6 +101,20 @@ public CredentialModel GetCachedCredentials()
108101
};
109102
}
110103

104+
// Implements ICoderApiClientCredentialProvider
105+
public CoderApiClientCredential? GetCoderApiClientCredential()
106+
{
107+
var latestCreds = _latestCredentials;
108+
if (latestCreds is not { State: CredentialState.Valid } || latestCreds.CoderUrl is null)
109+
return null;
110+
111+
return new CoderApiClientCredential
112+
{
113+
CoderUrl = latestCreds.CoderUrl,
114+
ApiToken = latestCreds.ApiToken ?? "",
115+
};
116+
}
117+
111118
public async Task<string?> GetSignInUri()
112119
{
113120
try
@@ -253,6 +260,12 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
253260
State = CredentialState.Invalid,
254261
};
255262

263+
if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri))
264+
return new CredentialModel
265+
{
266+
State = CredentialState.Invalid,
267+
};
268+
256269
BuildInfo buildInfo;
257270
User me;
258271
try
@@ -279,7 +292,7 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
279292
return new CredentialModel
280293
{
281294
State = CredentialState.Valid,
282-
CoderUrl = credentials.CoderUrl,
295+
CoderUrl = uri,
283296
ApiToken = credentials.ApiToken,
284297
Username = me.Username,
285298
};
@@ -298,6 +311,8 @@ private void UpdateState(CredentialModel newModel)
298311

299312
public class WindowsCredentialBackend : ICredentialBackend
300313
{
314+
public const string CoderCredentialsTargetName = "Coder.Desktop.App.Credentials";
315+
301316
private readonly string _credentialsTargetName;
302317

303318
public WindowsCredentialBackend(string credentialsTargetName)

‎App/Services/RpcController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ public async Task StartVpn(CancellationToken ct = default)
170170
{
171171
Start = new StartRequest
172172
{
173-
CoderUrl = credentials.CoderUrl,
173+
CoderUrl = credentials.CoderUrl?.ToString(),
174174
ApiToken = credentials.ApiToken,
175175
},
176176
}, ct);

‎App/Services/UserNotifier.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,3 @@ public Task ShowErrorNotification(string title, string message, CancellationToke
2727
return Task.CompletedTask;
2828
}
2929
}
30-

‎App/DisplayScale.cs renamed to ‎App/Utils/DisplayScale.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
using Microsoft.UI.Xaml;
44
using WinRT.Interop;
55

6-
namespace Coder.Desktop.App;
6+
namespace Coder.Desktop.App.Utils;
77

88
/// <summary>
99
/// A static utility class to house methods related to the visual scale of the display monitor.

‎App/Utils/ModelUpdate.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace Coder.Desktop.App.Utils;
6+
7+
public interface IModelUpdateable<in T>
8+
{
9+
/// <summary>
10+
/// Applies changes from obj to `this` if they represent the same
11+
/// object based on some identifier like an ID or fixed name.
12+
/// </summary>
13+
/// <returns>
14+
/// True if the two objects represent the same item and the changes
15+
/// were applied.
16+
/// </returns>
17+
public bool TryApplyChanges(T obj);
18+
}
19+
20+
/// <summary>
21+
/// A static utility class providing methods for applying model updates
22+
/// with as little UI updates as possible.
23+
/// The main goal of the utilities in this class is to prevent redraws in
24+
/// ItemsRepeater items when nothing has changed.
25+
/// </summary>
26+
public static class ModelUpdate
27+
{
28+
/// <summary>
29+
/// Takes all items in `update` and either applies them to existing
30+
/// items in `target`, or adds them to `target` if there are no
31+
/// matching items.
32+
/// Any items in `target` that don't have a corresponding item in
33+
/// `update` will be removed from `target`.
34+
/// Items are inserted in their correct sort position according to
35+
/// `sorter`. It's assumed that the target list is already sorted by
36+
/// `sorter`.
37+
/// </summary>
38+
/// <param name="target">Target list to be updated</param>
39+
/// <param name="update">Incoming list to apply to `target`</param>
40+
/// <param name="sorter">
41+
/// Comparison to use for sorting. Note that the sort order does not
42+
/// need to be the ID/name field used in the <c>IModelUpdateable</c>
43+
/// implementation, and can be by any order.
44+
/// New items will be sorted after existing items.
45+
/// </param>
46+
public static void ApplyLists<T>(IList<T> target, IEnumerable<T> update, Comparison<T> sorter)
47+
where T : IModelUpdateable<T>
48+
{
49+
var newItems = update.ToList();
50+
51+
// Update and remove existing items. We use index-based for loops here
52+
// because we remove items, and removing items while using the list as
53+
// an IEnumerable will throw an exception.
54+
for (var i = 0; i < target.Count; i++)
55+
{
56+
// Even though we're removing items before a "break", we still use
57+
// index-based for loops here to avoid exceptions.
58+
for (var j = 0; j < newItems.Count; j++)
59+
{
60+
if (!target[i].TryApplyChanges(newItems[j])) continue;
61+
62+
// Prevent it from being added below, or checked again. We
63+
// don't need to decrement `j` here because we're breaking
64+
// out of this inner loop.
65+
newItems.RemoveAt(j);
66+
goto OuterLoopEnd; // continue outer loop
67+
}
68+
69+
// A merge couldn't occur, so we need to remove the old item and
70+
// decrement `i` for the next iteration.
71+
target.RemoveAt(i);
72+
i--;
73+
74+
// Rider fights `dotnet format` about whether there should be a
75+
// space before the semicolon or not.
76+
#pragma warning disable format
77+
OuterLoopEnd: ;
78+
#pragma warning restore format
79+
}
80+
81+
// Add any items that were missing into their correct sorted place.
82+
// It's assumed the list is already sorted.
83+
foreach (var newItem in newItems)
84+
{
85+
for (var i = 0; i < target.Count; i++)
86+
// If the new item sorts before the current item, insert it
87+
// after.
88+
if (sorter(newItem, target[i]) < 0)
89+
{
90+
target.Insert(i, newItem);
91+
goto OuterLoopEnd;
92+
}
93+
94+
// Handle the case where target is empty or the new item is
95+
// equal to or after every other item.
96+
target.Add(newItem);
97+
98+
// Rider fights `dotnet format` about whether there should be a
99+
// space before the semicolon or not.
100+
#pragma warning disable format
101+
OuterLoopEnd: ;
102+
#pragma warning restore format
103+
}
104+
}
105+
}

‎App/Utils/TitleBarIcon.cs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
using System;
21
using Microsoft.UI;
32
using Microsoft.UI.Windowing;
43
using Microsoft.UI.Xaml;
5-
using Microsoft.UI.Xaml.Controls.Primitives;
64
using WinRT.Interop;
75

8-
namespace Coder.Desktop.App.Utils
6+
namespace Coder.Desktop.App.Utils;
7+
8+
public static class TitleBarIcon
99
{
10-
public static class TitleBarIcon
10+
public static void SetTitlebarIcon(Window window)
1111
{
12-
public static void SetTitlebarIcon(Window window)
13-
{
14-
var hwnd = WindowNative.GetWindowHandle(window);
15-
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
16-
AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico");
17-
}
12+
var hwnd = WindowNative.GetWindowHandle(window);
13+
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
14+
AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico");
1815
}
1916
}

‎App/ViewModels/AgentAppViewModel.cs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System;
2+
using System.Linq;
3+
using Windows.System;
4+
using Coder.Desktop.App.Models;
5+
using Coder.Desktop.App.Services;
6+
using Coder.Desktop.App.Utils;
7+
using Coder.Desktop.CoderSdk;
8+
using Coder.Desktop.Vpn.Proto;
9+
using CommunityToolkit.Mvvm.ComponentModel;
10+
using CommunityToolkit.Mvvm.Input;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.UI.Xaml;
13+
using Microsoft.UI.Xaml.Controls;
14+
using Microsoft.UI.Xaml.Controls.Primitives;
15+
using Microsoft.UI.Xaml.Media;
16+
using Microsoft.UI.Xaml.Media.Imaging;
17+
18+
namespace Coder.Desktop.App.ViewModels;
19+
20+
public interface IAgentAppViewModelFactory
21+
{
22+
public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl);
23+
}
24+
25+
public class AgentAppViewModelFactory(ILogger<AgentAppViewModel> childLogger, ICredentialManager credentialManager)
26+
: IAgentAppViewModelFactory
27+
{
28+
public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl)
29+
{
30+
return new AgentAppViewModel(childLogger, credentialManager)
31+
{
32+
Id = id,
33+
Name = name,
34+
AppUri = appUri,
35+
IconUrl = iconUrl,
36+
};
37+
}
38+
}
39+
40+
public partial class AgentAppViewModel : ObservableObject, IModelUpdateable<AgentAppViewModel>
41+
{
42+
private const string SessionTokenUriVar = "$SESSION_TOKEN";
43+
44+
private readonly ILogger<AgentAppViewModel> _logger;
45+
private readonly ICredentialManager _credentialManager;
46+
47+
public required Uuid Id { get; init; }
48+
49+
[ObservableProperty]
50+
[NotifyPropertyChangedFor(nameof(Details))]
51+
public required partial string Name { get; set; }
52+
53+
[ObservableProperty]
54+
[NotifyPropertyChangedFor(nameof(Details))]
55+
public required partial Uri AppUri { get; set; }
56+
57+
[ObservableProperty] public partial Uri? IconUrl { get; set; }
58+
59+
[ObservableProperty] public partial ImageSource IconImageSource { get; set; }
60+
61+
[ObservableProperty] public partial bool UseFallbackIcon { get; set; } = true;
62+
63+
public string Details =>
64+
(string.IsNullOrWhiteSpace(Name) ? "(no name)" : Name) + ":\n\n" + AppUri;
65+
66+
public AgentAppViewModel(ILogger<AgentAppViewModel> logger, ICredentialManager credentialManager)
67+
{
68+
_logger = logger;
69+
_credentialManager = credentialManager;
70+
71+
// Apply the icon URL to the icon image source when it is updated.
72+
IconImageSource = UpdateIcon();
73+
PropertyChanged += (_, args) =>
74+
{
75+
if (args.PropertyName == nameof(IconUrl))
76+
IconImageSource = UpdateIcon();
77+
};
78+
}
79+
80+
public bool TryApplyChanges(AgentAppViewModel obj)
81+
{
82+
if (Id != obj.Id) return false;
83+
84+
// To avoid spurious UI updates which cause flashing, don't actually
85+
// write to values unless they've changed.
86+
if (Name != obj.Name)
87+
Name = obj.Name;
88+
if (AppUri != obj.AppUri)
89+
AppUri = obj.AppUri;
90+
if (IconUrl != obj.IconUrl)
91+
{
92+
UseFallbackIcon = true;
93+
IconUrl = obj.IconUrl;
94+
}
95+
96+
return true;
97+
}
98+
99+
private ImageSource UpdateIcon()
100+
{
101+
if (IconUrl is null || (IconUrl.Scheme != "http" && IconUrl.Scheme != "https"))
102+
{
103+
UseFallbackIcon = true;
104+
return new BitmapImage();
105+
}
106+
107+
// Determine what image source to use based on extension, use a
108+
// BitmapImage as last resort.
109+
var ext = IconUrl.AbsolutePath.Split('/').LastOrDefault()?.Split('.').LastOrDefault();
110+
// TODO: this is definitely a hack, URLs shouldn't need to end in .svg
111+
if (ext is "svg")
112+
{
113+
// TODO: Some SVGs like `/icon/cursor.svg` contain PNG data and
114+
// don't render at all.
115+
var svg = new SvgImageSource(IconUrl);
116+
svg.Opened += (_, _) => _logger.LogDebug("app icon opened (svg): {uri}", IconUrl);
117+
svg.OpenFailed += (_, args) =>
118+
_logger.LogDebug("app icon failed to open (svg): {uri}: {Status}", IconUrl, args.Status);
119+
return svg;
120+
}
121+
122+
var bitmap = new BitmapImage(IconUrl);
123+
bitmap.ImageOpened += (_, _) => _logger.LogDebug("app icon opened (bitmap): {uri}", IconUrl);
124+
bitmap.ImageFailed += (_, args) =>
125+
_logger.LogDebug("app icon failed to open (bitmap): {uri}: {ErrorMessage}", IconUrl, args.ErrorMessage);
126+
return bitmap;
127+
}
128+
129+
public void OnImageOpened(object? sender, RoutedEventArgs e)
130+
{
131+
UseFallbackIcon = false;
132+
}
133+
134+
public void OnImageFailed(object? sender, RoutedEventArgs e)
135+
{
136+
UseFallbackIcon = true;
137+
}
138+
139+
[RelayCommand]
140+
private void OpenApp(object parameter)
141+
{
142+
try
143+
{
144+
var uri = AppUri;
145+
146+
// http and https URLs should already be filtered out by
147+
// AgentViewModel, but as a second line of defence don't do session
148+
// token var replacement on those URLs.
149+
if (uri.Scheme is not "http" and not "https")
150+
{
151+
var cred = _credentialManager.GetCachedCredentials();
152+
if (cred.State is CredentialState.Valid && cred.ApiToken is not null)
153+
uri = new Uri(uri.ToString().Replace(SessionTokenUriVar, cred.ApiToken));
154+
}
155+
156+
if (uri.ToString().Contains(SessionTokenUriVar))
157+
throw new Exception(
158+
$"URI contains {SessionTokenUriVar} variable but could not be replaced (http and https URLs cannot contain {SessionTokenUriVar})");
159+
160+
_ = Launcher.LaunchUriAsync(uri);
161+
}
162+
catch (Exception e)
163+
{
164+
_logger.LogWarning(e, "could not parse or launch app");
165+
166+
if (parameter is not FrameworkElement frameworkElement) return;
167+
var flyout = new Flyout
168+
{
169+
Content = new TextBlock
170+
{
171+
Text = $"Could not open app: {e.Message}",
172+
Margin = new Thickness(4),
173+
TextWrapping = TextWrapping.Wrap,
174+
},
175+
FlyoutPresenterStyle = new Style(typeof(FlyoutPresenter))
176+
{
177+
Setters =
178+
{
179+
new Setter(ScrollViewer.HorizontalScrollModeProperty, ScrollMode.Disabled),
180+
new Setter(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Disabled),
181+
},
182+
},
183+
};
184+
FlyoutBase.SetAttachedFlyout(frameworkElement, flyout);
185+
FlyoutBase.ShowAttachedFlyout(frameworkElement);
186+
}
187+
}
188+
}

‎App/ViewModels/AgentViewModel.cs

Lines changed: 337 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,53 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.ComponentModel;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
18
using Windows.ApplicationModel.DataTransfer;
9+
using Coder.Desktop.App.Services;
10+
using Coder.Desktop.App.Utils;
11+
using Coder.Desktop.CoderSdk;
12+
using Coder.Desktop.CoderSdk.Coder;
13+
using Coder.Desktop.Vpn.Proto;
14+
using CommunityToolkit.Mvvm.ComponentModel;
215
using CommunityToolkit.Mvvm.Input;
16+
using Microsoft.Extensions.Logging;
17+
using Microsoft.UI.Dispatching;
318
using Microsoft.UI.Xaml;
419
using Microsoft.UI.Xaml.Controls;
520
using Microsoft.UI.Xaml.Controls.Primitives;
621

722
namespace Coder.Desktop.App.ViewModels;
823

24+
public interface IAgentViewModelFactory
25+
{
26+
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix,
27+
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName);
28+
}
29+
30+
public class AgentViewModelFactory(
31+
ILogger<AgentViewModel> childLogger,
32+
ICoderApiClientFactory coderApiClientFactory,
33+
ICredentialManager credentialManager,
34+
IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory
35+
{
36+
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix,
37+
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName)
38+
{
39+
return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory,
40+
expanderHost, id)
41+
{
42+
Hostname = hostname,
43+
HostnameSuffix = hostnameSuffix,
44+
ConnectionStatus = connectionStatus,
45+
DashboardBaseUrl = dashboardBaseUrl,
46+
WorkspaceName = workspaceName,
47+
};
48+
}
49+
}
50+
951
public enum AgentConnectionStatus
1052
{
1153
Green,
@@ -14,17 +56,307 @@ public enum AgentConnectionStatus
1456
Gray,
1557
}
1658

17-
public partial class AgentViewModel
59+
public partial class AgentViewModel : ObservableObject, IModelUpdateable<AgentViewModel>
1860
{
19-
public required string Hostname { get; set; }
61+
private const string DefaultDashboardUrl = "https://coder.com";
62+
private const int MaxAppsPerRow = 6;
63+
64+
// These are fake UUIDs, for UI purposes only. Display apps don't exist on
65+
// the backend as real app resources and therefore don't have an ID.
66+
private static readonly Uuid VscodeAppUuid = new("819828b1-5213-4c3d-855e-1b74db6ddd19");
67+
private static readonly Uuid VscodeInsidersAppUuid = new("becf1e10-5101-4940-a853-59af86468069");
68+
69+
private readonly ILogger<AgentViewModel> _logger;
70+
private readonly ICoderApiClientFactory _coderApiClientFactory;
71+
private readonly ICredentialManager _credentialManager;
72+
private readonly IAgentAppViewModelFactory _agentAppViewModelFactory;
2073

21-
public required string HostnameSuffix { get; set; } // including leading dot
74+
// The AgentViewModel only gets created on the UI thread.
75+
private readonly DispatcherQueue _dispatcherQueue =
76+
DispatcherQueue.GetForCurrentThread();
2277

23-
public required AgentConnectionStatus ConnectionStatus { get; set; }
78+
private readonly IAgentExpanderHost _expanderHost;
79+
80+
// This isn't an ObservableProperty because the property itself never
81+
// changes. We add an event listener for the collection changing in the
82+
// constructor.
83+
public readonly ObservableCollection<AgentAppViewModel> Apps = [];
84+
85+
public readonly Uuid Id;
86+
87+
[ObservableProperty]
88+
[NotifyPropertyChangedFor(nameof(FullHostname))]
89+
public required partial string Hostname { get; set; }
90+
91+
[ObservableProperty]
92+
[NotifyPropertyChangedFor(nameof(FullHostname))]
93+
public required partial string HostnameSuffix { get; set; } // including leading dot
2494

2595
public string FullHostname => Hostname + HostnameSuffix;
2696

27-
public required string DashboardUrl { get; set; }
97+
[ObservableProperty]
98+
[NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))]
99+
[NotifyPropertyChangedFor(nameof(ExpandAppsMessage))]
100+
public required partial AgentConnectionStatus ConnectionStatus { get; set; }
101+
102+
[ObservableProperty]
103+
[NotifyPropertyChangedFor(nameof(DashboardUrl))]
104+
public required partial Uri DashboardBaseUrl { get; set; }
105+
106+
[ObservableProperty]
107+
[NotifyPropertyChangedFor(nameof(DashboardUrl))]
108+
public required partial string? WorkspaceName { get; set; }
109+
110+
[ObservableProperty] public partial bool IsExpanded { get; set; } = false;
111+
112+
[ObservableProperty]
113+
[NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))]
114+
[NotifyPropertyChangedFor(nameof(ExpandAppsMessage))]
115+
public partial bool FetchingApps { get; set; } = false;
116+
117+
[ObservableProperty]
118+
[NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))]
119+
[NotifyPropertyChangedFor(nameof(ExpandAppsMessage))]
120+
public partial bool AppFetchErrored { get; set; } = false;
121+
122+
// We only show 6 apps max, which fills the entire width of the tray
123+
// window.
124+
public IEnumerable<AgentAppViewModel> VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps;
125+
126+
public bool ShowExpandAppsMessage => ExpandAppsMessage != null;
127+
128+
public string? ExpandAppsMessage
129+
{
130+
get
131+
{
132+
if (ConnectionStatus == AgentConnectionStatus.Gray)
133+
return "Your workspace is offline.";
134+
if (FetchingApps && Apps.Count == 0)
135+
// Don't show this message if we have any apps already. When
136+
// they finish loading, we'll just update the screen with any
137+
// changes.
138+
return "Fetching workspace apps...";
139+
if (AppFetchErrored && Apps.Count == 0)
140+
// There's very limited screen real estate here so we don't
141+
// show the actual error message.
142+
return "Could not fetch workspace apps.";
143+
if (Apps.Count == 0)
144+
return "No apps to show.";
145+
return null;
146+
}
147+
}
148+
149+
public string DashboardUrl
150+
{
151+
get
152+
{
153+
if (string.IsNullOrWhiteSpace(WorkspaceName)) return DashboardBaseUrl.ToString();
154+
try
155+
{
156+
return new Uri(DashboardBaseUrl, $"/@me/{WorkspaceName}").ToString();
157+
}
158+
catch
159+
{
160+
return DefaultDashboardUrl;
161+
}
162+
}
163+
}
164+
165+
public AgentViewModel(ILogger<AgentViewModel> logger, ICoderApiClientFactory coderApiClientFactory,
166+
ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory,
167+
IAgentExpanderHost expanderHost, Uuid id)
168+
{
169+
_logger = logger;
170+
_coderApiClientFactory = coderApiClientFactory;
171+
_credentialManager = credentialManager;
172+
_agentAppViewModelFactory = agentAppViewModelFactory;
173+
_expanderHost = expanderHost;
174+
175+
Id = id;
176+
177+
PropertyChanged += (_, args) =>
178+
{
179+
if (args.PropertyName == nameof(IsExpanded))
180+
{
181+
_expanderHost.HandleAgentExpanded(Id, IsExpanded);
182+
183+
// Every time the drawer is expanded, re-fetch all apps.
184+
if (IsExpanded && !FetchingApps)
185+
FetchApps();
186+
}
187+
};
188+
189+
// Since the property value itself never changes, we add event
190+
// listeners for the underlying collection changing instead.
191+
Apps.CollectionChanged += (_, _) =>
192+
{
193+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleApps)));
194+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowExpandAppsMessage)));
195+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ExpandAppsMessage)));
196+
};
197+
}
198+
199+
public bool TryApplyChanges(AgentViewModel model)
200+
{
201+
if (Id != model.Id) return false;
202+
203+
// To avoid spurious UI updates which cause flashing, don't actually
204+
// write to values unless they've changed.
205+
if (Hostname != model.Hostname)
206+
Hostname = model.Hostname;
207+
if (HostnameSuffix != model.HostnameSuffix)
208+
HostnameSuffix = model.HostnameSuffix;
209+
if (ConnectionStatus != model.ConnectionStatus)
210+
ConnectionStatus = model.ConnectionStatus;
211+
if (DashboardBaseUrl != model.DashboardBaseUrl)
212+
DashboardBaseUrl = model.DashboardBaseUrl;
213+
if (WorkspaceName != model.WorkspaceName)
214+
WorkspaceName = model.WorkspaceName;
215+
216+
// Apps are not set externally.
217+
218+
return true;
219+
}
220+
221+
[RelayCommand]
222+
private void ToggleExpanded()
223+
{
224+
SetExpanded(!IsExpanded);
225+
}
226+
227+
public void SetExpanded(bool expanded)
228+
{
229+
if (IsExpanded == expanded) return;
230+
// This will bubble up to the TrayWindowViewModel because of the
231+
// PropertyChanged handler.
232+
IsExpanded = expanded;
233+
}
234+
235+
partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue)
236+
{
237+
if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps();
238+
}
239+
240+
private void FetchApps()
241+
{
242+
if (FetchingApps) return;
243+
FetchingApps = true;
244+
245+
// If the workspace is off, then there's no agent and there's no apps.
246+
if (ConnectionStatus == AgentConnectionStatus.Gray)
247+
{
248+
FetchingApps = false;
249+
Apps.Clear();
250+
return;
251+
}
252+
253+
// API client creation could fail, which would leave FetchingApps true.
254+
ICoderApiClient client;
255+
try
256+
{
257+
client = _coderApiClientFactory.Create(_credentialManager);
258+
}
259+
catch
260+
{
261+
FetchingApps = false;
262+
throw;
263+
}
264+
265+
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
266+
client.GetWorkspaceAgent(Id.ToString(), cts.Token).ContinueWith(t =>
267+
{
268+
cts.Dispose();
269+
ContinueFetchApps(t);
270+
}, CancellationToken.None);
271+
}
272+
273+
private void ContinueFetchApps(Task<WorkspaceAgent> task)
274+
{
275+
// Ensure we're on the UI thread.
276+
if (!_dispatcherQueue.HasThreadAccess)
277+
{
278+
_dispatcherQueue.TryEnqueue(() => ContinueFetchApps(task));
279+
return;
280+
}
281+
282+
FetchingApps = false;
283+
AppFetchErrored = !task.IsCompletedSuccessfully;
284+
if (!task.IsCompletedSuccessfully)
285+
{
286+
_logger.LogWarning(task.Exception, "Could not fetch workspace agent");
287+
return;
288+
}
289+
290+
var workspaceAgent = task.Result;
291+
var apps = new List<AgentAppViewModel>();
292+
foreach (var app in workspaceAgent.Apps)
293+
{
294+
if (!app.External || !string.IsNullOrEmpty(app.Command)) continue;
295+
296+
if (!Uri.TryCreate(app.Url, UriKind.Absolute, out var appUri))
297+
{
298+
_logger.LogWarning("Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list",
299+
app.Url,
300+
app.DisplayName);
301+
continue;
302+
}
303+
304+
// HTTP or HTTPS external apps are usually things like
305+
// wikis/documentation, which clutters up the app.
306+
if (appUri.Scheme is "http" or "https")
307+
continue;
308+
309+
// Icon parse failures are not fatal, we will just use the fallback
310+
// icon.
311+
_ = Uri.TryCreate(DashboardBaseUrl, app.Icon, out var iconUrl);
312+
313+
apps.Add(_agentAppViewModelFactory.Create(app.Id, app.DisplayName, appUri, iconUrl));
314+
}
315+
316+
foreach (var displayApp in workspaceAgent.DisplayApps)
317+
{
318+
if (displayApp is not WorkspaceAgent.DisplayAppVscode and not WorkspaceAgent.DisplayAppVscodeInsiders)
319+
continue;
320+
321+
var id = VscodeAppUuid;
322+
var displayName = "VS Code";
323+
var icon = "/icon/code.svg";
324+
var scheme = "vscode";
325+
if (displayApp is WorkspaceAgent.DisplayAppVscodeInsiders)
326+
{
327+
id = VscodeInsidersAppUuid;
328+
displayName = "VS Code Insiders";
329+
icon = "/icon/code-insiders.svg";
330+
scheme = "vscode-insiders";
331+
}
332+
333+
Uri appUri;
334+
try
335+
{
336+
appUri = new UriBuilder
337+
{
338+
Scheme = scheme,
339+
Host = "vscode-remote",
340+
Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}",
341+
}.Uri;
342+
}
343+
catch (Exception e)
344+
{
345+
_logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list",
346+
displayApp);
347+
continue;
348+
}
349+
350+
// Icon parse failures are not fatal, we will just use the fallback
351+
// icon.
352+
_ = Uri.TryCreate(DashboardBaseUrl, icon, out var iconUrl);
353+
354+
apps.Add(_agentAppViewModelFactory.Create(id, displayName, appUri, iconUrl));
355+
}
356+
357+
// Sort by name.
358+
ModelUpdate.ApplyLists(Apps, apps, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
359+
}
28360

29361
[RelayCommand]
30362
private void CopyHostname(object parameter)

‎App/ViewModels/FileSyncListViewModel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ public void OpenRemotePathSelectDialog()
339339
pickerViewModel.PathSelected += OnRemotePathSelected;
340340

341341
_remotePickerWindow = new DirectoryPickerWindow(pickerViewModel);
342-
_remotePickerWindow.SetParent(_window);
342+
if (_window is not null)
343+
_remotePickerWindow.SetParent(_window);
343344
_remotePickerWindow.Closed += (_, _) =>
344345
{
345346
_remotePickerWindow = null;

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 120 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.ComponentModel;
35
using System.Linq;
46
using System.Threading.Tasks;
57
using Coder.Desktop.App.Models;
68
using Coder.Desktop.App.Services;
9+
using Coder.Desktop.App.Utils;
710
using Coder.Desktop.App.Views;
11+
using Coder.Desktop.CoderSdk;
812
using Coder.Desktop.Vpn.Proto;
913
using CommunityToolkit.Mvvm.ComponentModel;
1014
using CommunityToolkit.Mvvm.Input;
@@ -13,23 +17,38 @@
1317
using Microsoft.UI.Dispatching;
1418
using Microsoft.UI.Xaml;
1519
using Microsoft.UI.Xaml.Controls;
16-
using Exception = System.Exception;
1720

1821
namespace Coder.Desktop.App.ViewModels;
1922

20-
public partial class TrayWindowViewModel : ObservableObject
23+
public interface IAgentExpanderHost
24+
{
25+
public void HandleAgentExpanded(Uuid id, bool expanded);
26+
}
27+
28+
public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
2129
{
2230
private const int MaxAgents = 5;
2331
private const string DefaultDashboardUrl = "https://coder.com";
2432

2533
private readonly IServiceProvider _services;
2634
private readonly IRpcController _rpcController;
2735
private readonly ICredentialManager _credentialManager;
36+
private readonly IAgentViewModelFactory _agentViewModelFactory;
2837

2938
private FileSyncListWindow? _fileSyncListWindow;
3039

3140
private DispatcherQueue? _dispatcherQueue;
3241

42+
// When we transition from 0 online workspaces to >0 online workspaces, the
43+
// first agent will be expanded. This bool tracks whether this has occurred
44+
// yet (or if the user has expanded something themselves).
45+
private bool _hasExpandedAgent;
46+
47+
// This isn't an ObservableProperty because the property itself never
48+
// changes. We add an event listener for the collection changing in the
49+
// constructor.
50+
public readonly ObservableCollection<AgentViewModel> Agents = [];
51+
3352
[ObservableProperty]
3453
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
3554
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
@@ -49,13 +68,6 @@ public partial class TrayWindowViewModel : ObservableObject
4968
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
5069
public partial string? VpnFailedMessage { get; set; } = null;
5170

52-
[ObservableProperty]
53-
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
54-
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
55-
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
56-
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
57-
public partial List<AgentViewModel> Agents { get; set; } = [];
58-
5971
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
6072

6173
public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started;
@@ -76,14 +88,43 @@ public partial class TrayWindowViewModel : ObservableObject
7688

7789
public IEnumerable<AgentViewModel> VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents);
7890

79-
[ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com";
91+
[ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl;
8092

8193
public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
82-
ICredentialManager credentialManager)
94+
ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory)
8395
{
8496
_services = services;
8597
_rpcController = rpcController;
8698
_credentialManager = credentialManager;
99+
_agentViewModelFactory = agentViewModelFactory;
100+
101+
// Since the property value itself never changes, we add event
102+
// listeners for the underlying collection changing instead.
103+
Agents.CollectionChanged += (_, _) =>
104+
{
105+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleAgents)));
106+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowNoAgentsSection)));
107+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentsSection)));
108+
OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentOverflowButton)));
109+
};
110+
}
111+
112+
// Implements IAgentExpanderHost
113+
public void HandleAgentExpanded(Uuid id, bool expanded)
114+
{
115+
// Ensure we're on the UI thread.
116+
if (_dispatcherQueue == null) return;
117+
if (!_dispatcherQueue.HasThreadAccess)
118+
{
119+
_dispatcherQueue.TryEnqueue(() => HandleAgentExpanded(id, expanded));
120+
return;
121+
}
122+
123+
if (!expanded) return;
124+
_hasExpandedAgent = true;
125+
// Collapse every other agent.
126+
foreach (var otherAgent in Agents.Where(a => a.Id != id))
127+
otherAgent.SetExpanded(false);
87128
}
88129

89130
public void Initialize(DispatcherQueue dispatcherQueue)
@@ -93,8 +134,8 @@ public void Initialize(DispatcherQueue dispatcherQueue)
93134
_rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
94135
UpdateFromRpcModel(_rpcController.GetState());
95136

96-
_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
97-
UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials());
137+
_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel);
138+
UpdateFromCredentialModel(_credentialManager.GetCachedCredentials());
98139
}
99140

100141
private void UpdateFromRpcModel(RpcModel rpcModel)
@@ -107,37 +148,30 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
107148
return;
108149
}
109150

110-
// As a failsafe, if RPC is disconnected we disable the switch. The
111-
// Window should not show the current Page if the RPC is disconnected.
112-
if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected)
151+
// As a failsafe, if RPC is disconnected (or we're not signed in) we
152+
// disable the switch. The Window should not show the current Page if
153+
// the RPC is disconnected.
154+
var credentialModel = _credentialManager.GetCachedCredentials();
155+
if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected || credentialModel.State is not CredentialState.Valid ||
156+
credentialModel.CoderUrl == null)
113157
{
114158
VpnLifecycle = VpnLifecycle.Unknown;
115159
VpnSwitchActive = false;
116-
Agents = [];
160+
Agents.Clear();
117161
return;
118162
}
119163

120164
VpnLifecycle = rpcModel.VpnLifecycle;
121165
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
122166

123-
// Get the current dashboard URL.
124-
var credentialModel = _credentialManager.GetCachedCredentials();
125-
Uri? coderUri = null;
126-
if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl))
127-
try
128-
{
129-
coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute);
130-
}
131-
catch
132-
{
133-
// Ignore
134-
}
135-
136167
// Add every known agent.
137168
HashSet<ByteString> workspacesWithAgents = [];
138169
List<AgentViewModel> agents = [];
139170
foreach (var agent in rpcModel.Agents)
140171
{
172+
if (!Uuid.TryFrom(agent.Id.Span, out var uuid))
173+
continue;
174+
141175
// Find the FQDN with the least amount of dots and split it into
142176
// prefix and suffix.
143177
var fqdn = agent.Fqdn
@@ -156,75 +190,95 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
156190
}
157191

158192
var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
193+
var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
194+
? AgentConnectionStatus.Green
195+
: AgentConnectionStatus.Yellow;
159196
workspacesWithAgents.Add(agent.WorkspaceId);
160197
var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId);
161198

162-
agents.Add(new AgentViewModel
163-
{
164-
Hostname = fqdnPrefix,
165-
HostnameSuffix = fqdnSuffix,
166-
ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
167-
? AgentConnectionStatus.Green
168-
: AgentConnectionStatus.Yellow,
169-
DashboardUrl = WorkspaceUri(coderUri, workspace?.Name),
170-
});
199+
agents.Add(_agentViewModelFactory.Create(
200+
this,
201+
uuid,
202+
fqdnPrefix,
203+
fqdnSuffix,
204+
connectionStatus,
205+
credentialModel.CoderUrl,
206+
workspace?.Name));
171207
}
172208

173209
// For every stopped workspace that doesn't have any agents, add a
174210
// dummy agent row.
175211
foreach (var workspace in rpcModel.Workspaces.Where(w =>
176212
w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id)))
177-
agents.Add(new AgentViewModel
178-
{
179-
// We just assume that it's a single-agent workspace.
180-
Hostname = workspace.Name,
213+
{
214+
if (!Uuid.TryFrom(workspace.Id.Span, out var uuid))
215+
continue;
216+
217+
agents.Add(_agentViewModelFactory.Create(
218+
this,
219+
// Workspace ID is fine as a stand-in here, it shouldn't
220+
// conflict with any agent IDs.
221+
uuid,
222+
// We assume that it's a single-agent workspace.
223+
workspace.Name,
181224
// TODO: this needs to get the suffix from the server
182-
HostnameSuffix = ".coder",
183-
ConnectionStatus = AgentConnectionStatus.Gray,
184-
DashboardUrl = WorkspaceUri(coderUri, workspace.Name),
185-
});
225+
".coder",
226+
AgentConnectionStatus.Gray,
227+
credentialModel.CoderUrl,
228+
workspace.Name));
229+
}
186230

187231
// Sort by status green, red, gray, then by hostname.
188-
agents.Sort((a, b) =>
232+
ModelUpdate.ApplyLists(Agents, agents, (a, b) =>
189233
{
190234
if (a.ConnectionStatus != b.ConnectionStatus)
191235
return a.ConnectionStatus.CompareTo(b.ConnectionStatus);
192236
return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal);
193237
});
194-
Agents = agents;
195238

196239
if (Agents.Count < MaxAgents) ShowAllAgents = false;
197-
}
198240

199-
private string WorkspaceUri(Uri? baseUri, string? workspaceName)
200-
{
201-
if (baseUri == null) return DefaultDashboardUrl;
202-
if (string.IsNullOrWhiteSpace(workspaceName)) return baseUri.ToString();
203-
try
204-
{
205-
return new Uri(baseUri, $"/@me/{workspaceName}").ToString();
206-
}
207-
catch
241+
var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray);
242+
if (firstOnlineAgent is null)
243+
_hasExpandedAgent = false;
244+
if (!_hasExpandedAgent && firstOnlineAgent is not null)
208245
{
209-
return DefaultDashboardUrl;
246+
firstOnlineAgent.SetExpanded(true);
247+
_hasExpandedAgent = true;
210248
}
211249
}
212250

213-
private void UpdateFromCredentialsModel(CredentialModel credentialModel)
251+
private void UpdateFromCredentialModel(CredentialModel credentialModel)
214252
{
215253
// Ensure we're on the UI thread.
216254
if (_dispatcherQueue == null) return;
217255
if (!_dispatcherQueue.HasThreadAccess)
218256
{
219-
_dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
257+
_dispatcherQueue.TryEnqueue(() => UpdateFromCredentialModel(credentialModel));
220258
return;
221259
}
222260

261+
// CredentialModel updates trigger RpcStateModel updates first. This
262+
// resolves an issue on startup where the window would be locked for 5
263+
// seconds, even if all startup preconditions have been met:
264+
//
265+
// 1. RPC state updates, but credentials are invalid so the window
266+
// enters the invalid loading state to prevent interaction.
267+
// 2. Credential model finally becomes valid after reaching out to the
268+
// server to check credentials.
269+
// 3. UpdateFromCredentialModel previously did not re-trigger RpcModel
270+
// update.
271+
// 4. Five seconds after step 1, a new RPC state update would come in
272+
// and finally unlock the window.
273+
//
274+
// Calling UpdateFromRpcModel at step 3 resolves this issue.
275+
UpdateFromRpcModel(_rpcController.GetState());
276+
223277
// HACK: the HyperlinkButton crashes the whole app if the initial URI
224278
// or this URI is invalid. CredentialModel.CoderUrl should never be
225279
// null while the Page is active as the Page is only displayed when
226280
// CredentialModel.Status == Valid.
227-
DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl;
281+
DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl;
228282
}
229283

230284
public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
@@ -273,13 +327,13 @@ private static string MaybeUnwrapTunnelError(Exception e)
273327
}
274328

275329
[RelayCommand]
276-
public void ToggleShowAllAgents()
330+
private void ToggleShowAllAgents()
277331
{
278332
ShowAllAgents = !ShowAllAgents;
279333
}
280334

281335
[RelayCommand]
282-
public void ShowFileSyncListWindow()
336+
private void ShowFileSyncListWindow()
283337
{
284338
// This is safe against concurrent access since it all happens in the
285339
// UI thread.
@@ -295,7 +349,7 @@ public void ShowFileSyncListWindow()
295349
}
296350

297351
[RelayCommand]
298-
public void SignOut()
352+
private void SignOut()
299353
{
300354
if (VpnLifecycle is not VpnLifecycle.Stopped)
301355
return;

‎App/Views/DirectoryPickerWindow.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
using System;
22
using System.Runtime.InteropServices;
33
using Windows.Graphics;
4+
using Coder.Desktop.App.Utils;
45
using Coder.Desktop.App.ViewModels;
56
using Coder.Desktop.App.Views.Pages;
67
using Microsoft.UI.Windowing;
78
using Microsoft.UI.Xaml;
89
using Microsoft.UI.Xaml.Media;
910
using WinRT.Interop;
1011
using WinUIEx;
11-
using Coder.Desktop.App.Utils;
1212

1313
namespace Coder.Desktop.App.Views;
1414

‎App/Views/FileSyncListWindow.xaml.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
using Coder.Desktop.App.Utils;
12
using Coder.Desktop.App.ViewModels;
23
using Coder.Desktop.App.Views.Pages;
34
using Microsoft.UI.Xaml.Media;
45
using WinUIEx;
5-
using Coder.Desktop.App.Utils;
66

77
namespace Coder.Desktop.App.Views;
88

@@ -23,5 +23,4 @@ public FileSyncListWindow(FileSyncListViewModel viewModel)
2323

2424
this.CenterOnScreen();
2525
}
26-
2726
}

‎App/Views/Pages/SignInTokenPage.xaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@
8787
<Button
8888
Content="Back" HorizontalAlignment="Right"
8989
Command="{x:Bind ViewModel.TokenPage_BackCommand, Mode=OneWay}"
90-
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
90+
CommandParameter="{x:Bind SignInWindow}" />
9191

9292
<Button
9393
Content="Sign In"
9494
HorizontalAlignment="Left"
9595
Style="{StaticResource AccentButtonStyle}"
9696
Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
97-
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
97+
CommandParameter="{x:Bind SignInWindow}" />
9898
</StackPanel>
9999
</StackPanel>
100100
</Page>

‎App/Views/Pages/SignInUrlPage.xaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
Content="Next"
6363
HorizontalAlignment="Center"
6464
Command="{x:Bind ViewModel.UrlPage_NextCommand, Mode=OneWay}"
65-
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}"
65+
CommandParameter="{x:Bind SignInWindow}"
6666
Style="{StaticResource AccentButtonStyle}" />
6767
</StackPanel>
6868
</Page>

‎App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 188 additions & 97 deletions
Large diffs are not rendered by default.

‎App/Views/SignInWindow.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
using System;
22
using Windows.Graphics;
33
using Coder.Desktop.App.Controls;
4+
using Coder.Desktop.App.Utils;
45
using Coder.Desktop.App.ViewModels;
56
using Coder.Desktop.App.Views.Pages;
67
using Microsoft.UI.Windowing;
78
using Microsoft.UI.Xaml;
89
using Microsoft.UI.Xaml.Media;
9-
using Coder.Desktop.App.Utils;
1010

1111
namespace Coder.Desktop.App.Views;
1212

‎App/Views/TrayWindow.xaml.cs

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Coder.Desktop.App.Controls;
77
using Coder.Desktop.App.Models;
88
using Coder.Desktop.App.Services;
9+
using Coder.Desktop.App.Utils;
910
using Coder.Desktop.App.Views.Pages;
1011
using CommunityToolkit.Mvvm.Input;
1112
using Microsoft.UI;
@@ -24,6 +25,7 @@ public sealed partial class TrayWindow : Window
2425
private const int WIDTH = 300;
2526

2627
private NativeApi.POINT? _lastActivatePosition;
28+
private int _maxHeightSinceLastActivation;
2729

2830
private readonly IRpcController _rpcController;
2931
private readonly ICredentialManager _credentialManager;
@@ -138,30 +140,22 @@ public void SetRootFrame(Page page)
138140

139141
private void RootFrame_SizeChanged(object sender, SizedFrameEventArgs e)
140142
{
141-
ResizeWindow(e.NewSize.Height);
142-
MoveWindow();
143+
MoveAndResize(e.NewSize.Height);
143144
}
144145

145-
private void ResizeWindow()
146+
private void MoveAndResize(double height)
146147
{
147-
ResizeWindow(RootFrame.GetContentSize().Height);
148-
}
149-
150-
private void ResizeWindow(double height)
151-
{
152-
if (height <= 0) height = 100; // will be resolved next frame typically
153-
154-
var scale = DisplayScale.WindowScale(this);
155-
var newWidth = (int)(WIDTH * scale);
156-
var newHeight = (int)(height * scale);
157-
AppWindow.Resize(new SizeInt32(newWidth, newHeight));
148+
var size = CalculateWindowSize(height);
149+
var pos = CalculateWindowPosition(size);
150+
var rect = new RectInt32(pos.X, pos.Y, size.Width, size.Height);
151+
AppWindow.MoveAndResize(rect);
158152
}
159153

160154
private void MoveResizeAndActivate()
161155
{
162156
SaveCursorPos();
163-
ResizeWindow();
164-
MoveWindow();
157+
_maxHeightSinceLastActivation = 0;
158+
MoveAndResize(RootFrame.GetContentSize().Height);
165159
AppWindow.Show();
166160
NativeApi.SetForegroundWindow(WindowNative.GetWindowHandle(this));
167161
}
@@ -178,15 +172,33 @@ private void SaveCursorPos()
178172
_lastActivatePosition = null;
179173
}
180174

181-
private void MoveWindow()
175+
private SizeInt32 CalculateWindowSize(double height)
182176
{
183-
AppWindow.Move(GetWindowPosition());
177+
if (height <= 0) height = 100; // will be resolved next frame typically
178+
179+
var scale = DisplayScale.WindowScale(this);
180+
var newWidth = (int)(WIDTH * scale);
181+
var newHeight = (int)(height * scale);
182+
// Store the maximum height we've seen for positioning purposes.
183+
if (newHeight > _maxHeightSinceLastActivation)
184+
_maxHeightSinceLastActivation = newHeight;
185+
186+
return new SizeInt32(newWidth, newHeight);
184187
}
185188

186-
private PointInt32 GetWindowPosition()
189+
private PointInt32 CalculateWindowPosition(SizeInt32 size)
187190
{
188-
var height = AppWindow.Size.Height;
189-
var width = AppWindow.Size.Width;
191+
var width = size.Width;
192+
var height = size.Height;
193+
// For positioning purposes, pretend the window is the maximum size it
194+
// has been since it was last activated. This has the affect of
195+
// allowing the window to move up to accomodate more content, but
196+
// prevents it from moving back down when the window shrinks again.
197+
//
198+
// Prevents a lot of jittery behavior with app drawers.
199+
if (height < _maxHeightSinceLastActivation)
200+
height = _maxHeightSinceLastActivation;
201+
190202
var cursorPosition = _lastActivatePosition;
191203
if (cursorPosition is null)
192204
{

‎App/packages.lock.json

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
"Microsoft.WindowsAppSDK": "1.6.250108002"
1919
}
2020
},
21+
"CommunityToolkit.WinUI.Extensions": {
22+
"type": "Direct",
23+
"requested": "[8.2.250402, )",
24+
"resolved": "8.2.250402",
25+
"contentHash": "rAOYzNX6kdUeeE1ejGd6Q8B+xmyZvOrWFUbqCgOtP8OQsOL66en9ZQTtzxAlaaFC4qleLvnKcn8FJFBezujOlw==",
26+
"dependencies": {
27+
"CommunityToolkit.Common": "8.2.1",
28+
"Microsoft.WindowsAppSDK": "1.6.250108002"
29+
}
30+
},
2131
"DependencyPropertyGenerator": {
2232
"type": "Direct",
2333
"requested": "[1.5.0, )",
@@ -142,15 +152,6 @@
142152
"resolved": "8.2.1",
143153
"contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw=="
144154
},
145-
"CommunityToolkit.WinUI.Extensions": {
146-
"type": "Transitive",
147-
"resolved": "8.2.250402",
148-
"contentHash": "rAOYzNX6kdUeeE1ejGd6Q8B+xmyZvOrWFUbqCgOtP8OQsOL66en9ZQTtzxAlaaFC4qleLvnKcn8FJFBezujOlw==",
149-
"dependencies": {
150-
"CommunityToolkit.Common": "8.2.1",
151-
"Microsoft.WindowsAppSDK": "1.6.250108002"
152-
}
153-
},
154155
"Google.Protobuf": {
155156
"type": "Transitive",
156157
"resolved": "3.29.3",

‎Coder.Desktop.sln

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.App", "Tests.App\Test
2727
EndProject
2828
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MutagenSdk", "MutagenSdk\MutagenSdk.csproj", "{E2477ADC-03DA-490D-9369-79A4CC4A58D2}"
2929
EndProject
30+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.CoderSdk", "Tests.CoderSdk\Tests.CoderSdk.csproj", "{2BDEA023-FE75-476F-81DE-8EF90806C27C}"
31+
EndProject
3032
Global
3133
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3234
Debug|Any CPU = Debug|Any CPU
@@ -239,6 +241,22 @@ Global
239241
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x64.Build.0 = Release|Any CPU
240242
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.ActiveCfg = Release|Any CPU
241243
{E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.Build.0 = Release|Any CPU
244+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
245+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|Any CPU.Build.0 = Debug|Any CPU
246+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|ARM64.ActiveCfg = Debug|Any CPU
247+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|ARM64.Build.0 = Debug|Any CPU
248+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|x64.ActiveCfg = Debug|Any CPU
249+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|x64.Build.0 = Debug|Any CPU
250+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|x86.ActiveCfg = Debug|Any CPU
251+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Debug|x86.Build.0 = Debug|Any CPU
252+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|Any CPU.ActiveCfg = Release|Any CPU
253+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|Any CPU.Build.0 = Release|Any CPU
254+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|ARM64.ActiveCfg = Release|Any CPU
255+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|ARM64.Build.0 = Release|Any CPU
256+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x64.ActiveCfg = Release|Any CPU
257+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x64.Build.0 = Release|Any CPU
258+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x86.ActiveCfg = Release|Any CPU
259+
{2BDEA023-FE75-476F-81DE-8EF90806C27C}.Release|x86.Build.0 = Release|Any CPU
242260
EndGlobalSection
243261
GlobalSection(SolutionProperties) = preSolution
244262
HideSolutionNode = FALSE

‎Coder.Desktop.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_NESTED_LOCK_STMT/@EntryValue">True</s:Boolean>
66
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_NESTED_USINGS_STMT/@EntryValue">True</s:Boolean>
77
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_NESTED_WHILE_STMT/@EntryValue">True</s:Boolean>
8+
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/OUTDENT_STATEMENT_LABELS/@EntryValue">True</s:Boolean>
89
<s:String x:Key="/Default/CodeStyle/CSharpFileLayoutPatterns/Pattern/@EntryValue">&lt;Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"&gt;
910
&lt;TypePattern DisplayName="Non-reorderable types" Priority="99999999"&gt;
1011
&lt;TypePattern.Match&gt;

‎CoderSdk/Coder/CoderApiClient.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ namespace Coder.Desktop.CoderSdk.Coder;
55
public interface ICoderApiClientFactory
66
{
77
public ICoderApiClient Create(string baseUrl);
8+
public ICoderApiClient Create(ICoderApiClientCredentialProvider provider);
9+
}
10+
11+
public class CoderApiClientCredential
12+
{
13+
public required Uri CoderUrl { get; set; }
14+
public required string ApiToken { get; set; }
15+
}
16+
17+
public interface ICoderApiClientCredentialProvider
18+
{
19+
public CoderApiClientCredential? GetCoderApiClientCredential();
820
}
921

1022
public class CoderApiClientFactory : ICoderApiClientFactory
@@ -13,6 +25,23 @@ public ICoderApiClient Create(string baseUrl)
1325
{
1426
return new CoderApiClient(baseUrl);
1527
}
28+
29+
public ICoderApiClient Create(ICoderApiClientCredentialProvider provider)
30+
{
31+
var cred = provider.GetCoderApiClientCredential();
32+
if (cred == null)
33+
throw new InvalidOperationException(
34+
"Cannot create Coder API client with invalid credential provider: credential is null");
35+
36+
var client = Create(cred.CoderUrl);
37+
client.SetSessionToken(cred.ApiToken);
38+
return client;
39+
}
40+
41+
public ICoderApiClient Create(Uri baseUrl)
42+
{
43+
return new CoderApiClient(baseUrl);
44+
}
1645
}
1746

1847
public partial interface ICoderApiClient
@@ -24,6 +53,8 @@ public partial interface ICoderApiClient
2453
[JsonSerializable(typeof(Response))]
2554
[JsonSerializable(typeof(User))]
2655
[JsonSerializable(typeof(ValidationError))]
56+
[JsonSerializable(typeof(WorkspaceAgent))]
57+
[JsonSerializable(typeof(WorkspaceApp))]
2758
public partial class CoderApiJsonContext : JsonSerializerContext;
2859

2960
/// <summary>

‎CoderSdk/Coder/WorkspaceAgents.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace Coder.Desktop.CoderSdk.Coder;
2+
3+
public partial interface ICoderApiClient
4+
{
5+
public Task<WorkspaceAgent> GetWorkspaceAgent(string id, CancellationToken ct = default);
6+
}
7+
8+
public class WorkspaceAgent
9+
{
10+
public const string DisplayAppVscode = "vscode";
11+
public const string DisplayAppVscodeInsiders = "vscode_insiders";
12+
13+
public string ExpandedDirectory { get; set; } = "";
14+
15+
public WorkspaceApp[] Apps { get; set; } = [];
16+
17+
// This isn't an enum to avoid future display apps breaking the desktop
18+
// app.
19+
public string[] DisplayApps { get; set; } = [];
20+
}
21+
22+
public class WorkspaceApp
23+
{
24+
public Uuid Id { get; set; } = Uuid.Zero;
25+
public string Url { get; set; } = string.Empty;
26+
public bool External { get; set; } = false;
27+
public string DisplayName { get; set; } = string.Empty;
28+
public string Command { get; set; } = string.Empty;
29+
public string Icon { get; set; } = string.Empty;
30+
}
31+
32+
public partial class CoderApiClient
33+
{
34+
public Task<WorkspaceAgent> GetWorkspaceAgent(string id, CancellationToken ct = default)
35+
{
36+
return SendRequestNoBodyAsync<WorkspaceAgent>(HttpMethod.Get, "/api/v2/workspaceagents/" + id, ct);
37+
}
38+
}

‎CoderSdk/Uuid.cs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
6+
namespace Coder.Desktop.CoderSdk;
7+
8+
/// <summary>
9+
/// A simplistic UUIDv4 class that wraps a 16-byte array. We don't use the
10+
/// native Guid class because it has some weird reordering behavior due to
11+
/// legacy Windows stuff. This class is not guaranteed to provide full RFC
12+
/// 4122 compliance, but it should provide enough coverage for Coder
13+
/// Desktop.
14+
/// </summary>
15+
[JsonConverter(typeof(UuidJsonConverter))]
16+
public class Uuid
17+
{
18+
private readonly byte[] _bytes;
19+
20+
/// <summary>
21+
/// The (invalid) zero UUID.
22+
/// </summary>
23+
public static Uuid Zero { get; } = new();
24+
25+
public ReadOnlySpan<byte> Bytes => _bytes;
26+
27+
private Uuid()
28+
{
29+
_bytes = new byte[16];
30+
}
31+
32+
public Uuid(ReadOnlySpan<byte> bytes)
33+
{
34+
if (bytes.Length != 16)
35+
throw new ArgumentException($"UUID must be 16 bytes, but was {bytes.Length} bytes", nameof(bytes));
36+
if (bytes[6] >> 4 != 0x4)
37+
throw new ArgumentException("ID does not seem like a valid UUIDv4", nameof(bytes));
38+
_bytes = bytes.ToArray();
39+
}
40+
41+
public Uuid(string str)
42+
{
43+
if (str.Length != 36)
44+
throw new ArgumentException($"UUID string must be 36 characters, but was {str.Length} characters",
45+
nameof(str));
46+
47+
var currentIndex = 0;
48+
_bytes = new byte[16];
49+
50+
for (var i = 0; i < 36; i++)
51+
{
52+
if (i is 8 or 13 or 18 or 23)
53+
{
54+
if (str[i] != '-')
55+
throw new ArgumentException("UUID string must have dashes at positions 8, 13, 18, and 23",
56+
nameof(str));
57+
continue;
58+
}
59+
60+
// Take two hex digits and convert them to a byte.
61+
var hex = str[i..(i + 2)];
62+
if (!byte.TryParse(hex, NumberStyles.HexNumber, null, out var b))
63+
throw new ArgumentException($"UUID string has invalid hex digits at position {i}", nameof(str));
64+
_bytes[currentIndex] = b;
65+
currentIndex++;
66+
67+
// Advance the loop index by 1 as we processed two characters.
68+
i++;
69+
}
70+
71+
if (currentIndex != 16)
72+
throw new ArgumentException($"UUID string must have 16 bytes, but was {currentIndex} bytes", nameof(str));
73+
if (_bytes[6] >> 4 != 0x4)
74+
throw new ArgumentException("ID does not seem like a valid UUIDv4", nameof(str));
75+
}
76+
77+
public static bool TryFrom(ReadOnlySpan<byte> bytes, out Uuid uuid)
78+
{
79+
try
80+
{
81+
uuid = new Uuid(bytes);
82+
return true;
83+
}
84+
catch
85+
{
86+
uuid = Zero;
87+
return false;
88+
}
89+
}
90+
91+
public static bool TryParse(string str, out Uuid uuid)
92+
{
93+
try
94+
{
95+
uuid = new Uuid(str);
96+
return true;
97+
}
98+
catch
99+
{
100+
uuid = Zero;
101+
return false;
102+
}
103+
}
104+
105+
public override string ToString()
106+
{
107+
if (_bytes.Length != 16)
108+
throw new ArgumentException($"UUID must be 16 bytes, but was {_bytes.Length} bytes", nameof(_bytes));
109+
110+
// Print every byte as hex, with dashes in the right places.
111+
var sb = new StringBuilder(36);
112+
for (var i = 0; i < 16; i++)
113+
{
114+
if (i is 4 or 6 or 8 or 10)
115+
sb.Append('-');
116+
sb.Append(_bytes[i].ToString("x2"));
117+
}
118+
119+
return sb.ToString();
120+
}
121+
122+
#region Uuid equality
123+
124+
public override bool Equals(object? obj)
125+
{
126+
return obj is Uuid other && Equals(other);
127+
}
128+
129+
public bool Equals(Uuid? other)
130+
{
131+
return other is not null && _bytes.SequenceEqual(other._bytes);
132+
}
133+
134+
public override int GetHashCode()
135+
{
136+
return _bytes.GetHashCode();
137+
}
138+
139+
public static bool operator ==(Uuid left, Uuid right)
140+
{
141+
return left.Equals(right);
142+
}
143+
144+
public static bool operator !=(Uuid left, Uuid right)
145+
{
146+
return !left.Equals(right);
147+
}
148+
149+
#endregion
150+
}
151+
152+
public class UuidJsonConverter : JsonConverter<Uuid>
153+
{
154+
public override Uuid? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
155+
{
156+
if (reader.TokenType == JsonTokenType.Null)
157+
return null;
158+
159+
if (reader.TokenType != JsonTokenType.String)
160+
throw new JsonException("Expected string token type for UUID");
161+
162+
var str = reader.GetString();
163+
if (str == null)
164+
return null;
165+
166+
try
167+
{
168+
return new Uuid(str);
169+
}
170+
catch (Exception ex)
171+
{
172+
throw new JsonException($"Invalid UUID string '{str}'", ex);
173+
}
174+
}
175+
176+
public override void Write(Utf8JsonWriter writer, Uuid value, JsonSerializerOptions options)
177+
{
178+
writer.WriteStringValue(value.ToString());
179+
}
180+
}

‎Tests.App/Converters/FriendlyByteConverterTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public void EndToEnd()
2929
var converter = new FriendlyByteConverter();
3030
foreach (var (input, expected) in cases)
3131
{
32-
var actual = converter.Convert(input, typeof(string), null, null);
32+
var actual = converter.Convert(input, typeof(string), null!, null!);
3333
Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}");
3434
}
3535
}

‎Tests.App/Services/CredentialManagerTest.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Coder.Desktop.Tests.App.Services;
99
[TestFixture]
1010
public class CredentialManagerTest
1111
{
12-
private const string TestServerUrl = "https://dev.coder.com";
12+
private const string TestServerUrl = "https://dev.coder.com/";
1313
private const string TestApiToken = "abcdef1234-abcdef1234567890ABCDEF";
1414
private const string TestUsername = "dean";
1515

@@ -50,7 +50,7 @@ public async Task EndToEnd(CancellationToken ct)
5050
// Cached credential should be valid.
5151
cred = manager1.GetCachedCredentials();
5252
Assert.That(cred.State, Is.EqualTo(CredentialState.Valid));
53-
Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl));
53+
Assert.That(cred.CoderUrl?.ToString(), Is.EqualTo(TestServerUrl));
5454
Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken));
5555
Assert.That(cred.Username, Is.EqualTo(TestUsername));
5656

@@ -62,7 +62,7 @@ public async Task EndToEnd(CancellationToken ct)
6262
var manager2 = new CredentialManager(credentialBackend, apiClientFactory.Object);
6363
cred = await manager2.LoadCredentials(ct).WaitAsync(ct);
6464
Assert.That(cred.State, Is.EqualTo(CredentialState.Valid));
65-
Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl));
65+
Assert.That(cred.CoderUrl?.ToString(), Is.EqualTo(TestServerUrl));
6666
Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken));
6767
Assert.That(cred.Username, Is.EqualTo(TestUsername));
6868

‎Tests.App/Utils/ModelUpdateTest.cs

Lines changed: 413 additions & 0 deletions
Large diffs are not rendered by default.

‎Tests.CoderSdk/Tests.CoderSdk.csproj

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<AssemblyName>Coder.Desktop.Tests.CoderSdk</AssemblyName>
5+
<RootNamespace>Coder.Desktop.Tests.CoderSdk</RootNamespace>
6+
<TargetFramework>net8.0</TargetFramework>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
10+
<IsPackable>false</IsPackable>
11+
<IsTestProject>true</IsTestProject>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="coverlet.collector" Version="6.0.4">
16+
<PrivateAssets>all</PrivateAssets>
17+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18+
</PackageReference>
19+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
20+
<PackageReference Include="NUnit" Version="4.3.2" />
21+
<PackageReference Include="NUnit.Analyzers" Version="4.6.0">
22+
<PrivateAssets>all</PrivateAssets>
23+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
24+
</PackageReference>
25+
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
26+
</ItemGroup>
27+
28+
<ItemGroup>
29+
<Using Include="NUnit.Framework" />
30+
</ItemGroup>
31+
32+
<ItemGroup>
33+
<ProjectReference Include="..\CoderSdk\CoderSdk.csproj" />
34+
</ItemGroup>
35+
36+
</Project>

‎Tests.CoderSdk/UuidTest.cs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using Coder.Desktop.CoderSdk;
2+
3+
namespace Coder.Desktop.Tests.CoderSdk;
4+
5+
[TestFixture]
6+
public class UuidTest
7+
{
8+
private const string UuidStr = "df762f71-898c-44a2-84c6-8add83704266";
9+
10+
private static readonly byte[] UuidBytes =
11+
[0xdf, 0x76, 0x2f, 0x71, 0x89, 0x8c, 0x44, 0xa2, 0x84, 0xc6, 0x8a, 0xdd, 0x83, 0x70, 0x42, 0x66];
12+
13+
[Test(Description = "Convert UUID bytes => Uuid")]
14+
public void BytesToUuid()
15+
{
16+
var uuid = new Uuid(UuidBytes);
17+
Assert.That(uuid.ToString(), Is.EqualTo(UuidStr));
18+
Assert.That(uuid.Bytes.ToArray(), Is.EqualTo(UuidBytes));
19+
}
20+
21+
[Test(Description = "Convert UUID string => Uuid")]
22+
public void StringToUuid()
23+
{
24+
var uuid = new Uuid(UuidStr);
25+
Assert.That(uuid.ToString(), Is.EqualTo(UuidStr));
26+
Assert.That(uuid.Bytes.ToArray(), Is.EqualTo(UuidBytes));
27+
}
28+
29+
[Test(Description = "Convert capitalized UUID string => Uuid")]
30+
public void CapitalizedStringToUuid()
31+
{
32+
var uuid = new Uuid(UuidStr.ToUpper());
33+
// The capitalized string should be discarded after parsing.
34+
Assert.That(uuid.ToString(), Is.EqualTo(UuidStr));
35+
Assert.That(uuid.Bytes.ToArray(), Is.EqualTo(UuidBytes));
36+
}
37+
38+
[Test(Description = "Invalid length")]
39+
public void InvalidLength()
40+
{
41+
var ex = Assert.Throws<ArgumentException>(() => _ = new Uuid([]));
42+
Assert.That(ex.Message, Does.Contain("UUID must be 16 bytes, but was 0 bytes"));
43+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(UuidBytes.AsSpan(..^1)));
44+
Assert.That(ex.Message, Does.Contain("UUID must be 16 bytes, but was 15 bytes"));
45+
var longerBytes = UuidBytes.Append((byte)0x0).ToArray();
46+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(longerBytes));
47+
Assert.That(ex.Message, Does.Contain("UUID must be 16 bytes, but was 17 bytes"));
48+
49+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(""));
50+
Assert.That(ex.Message, Does.Contain("UUID string must be 36 characters, but was 0 characters"));
51+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(UuidStr[..^1]));
52+
Assert.That(ex.Message, Does.Contain("UUID string must be 36 characters, but was 35 characters"));
53+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(UuidStr + "0"));
54+
Assert.That(ex.Message, Does.Contain("UUID string must be 36 characters, but was 37 characters"));
55+
}
56+
57+
[Test(Description = "Invalid version")]
58+
public void InvalidVersion()
59+
{
60+
var invalidVersionBytes = new byte[16];
61+
Array.Copy(UuidBytes, invalidVersionBytes, UuidBytes.Length);
62+
invalidVersionBytes[6] = (byte)(invalidVersionBytes[6] & 0x0f); // clear version nibble
63+
var ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(invalidVersionBytes));
64+
Assert.That(ex.Message, Does.Contain("ID does not seem like a valid UUIDv4"));
65+
66+
var invalidVersionChars = UuidStr.ToCharArray();
67+
invalidVersionChars[14] = '0'; // clear version nibble
68+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(new string(invalidVersionChars)));
69+
Assert.That(ex.Message, Does.Contain("ID does not seem like a valid UUIDv4"));
70+
}
71+
72+
[Test(Description = "Invalid string")]
73+
public void InvalidString()
74+
{
75+
var hyphenMissing = UuidStr.Replace("-", "0");
76+
var ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(hyphenMissing));
77+
Assert.That(ex.Message, Does.Contain("UUID string must have dashes at positions 8, 13, 18, and 23"));
78+
79+
var invalidHex = UuidStr.ToCharArray();
80+
invalidHex[2] = 'g'; // invalid hex digit
81+
ex = Assert.Throws<ArgumentException>(() => _ = new Uuid(new string(invalidHex)));
82+
Assert.That(ex.Message, Does.Contain("UUID string has invalid hex digits at position 2"));
83+
}
84+
85+
[Test(Description = "Try methods")]
86+
public void Try()
87+
{
88+
Assert.That(Uuid.TryFrom(UuidBytes, out var uuid1), Is.True);
89+
Assert.That(uuid1.Bytes.ToArray(), Is.EqualTo(UuidBytes));
90+
91+
Assert.That(Uuid.TryFrom([], out var uuid2), Is.False);
92+
Assert.That(uuid2, Is.EqualTo(Uuid.Zero));
93+
94+
Assert.That(Uuid.TryParse(UuidStr, out var uuid3), Is.True);
95+
Assert.That(uuid3.ToString(), Is.EqualTo(UuidStr));
96+
97+
Assert.That(Uuid.TryParse("", out var uuid4), Is.False);
98+
Assert.That(uuid4, Is.EqualTo(Uuid.Zero));
99+
}
100+
101+
[Test(Description = "Equality")]
102+
public void Equality()
103+
{
104+
var differentUuidBytes = new byte[16];
105+
Array.Copy(UuidBytes, differentUuidBytes, UuidBytes.Length);
106+
differentUuidBytes[0] = (byte)(differentUuidBytes[0] + 1);
107+
108+
var uuid1 = new Uuid(UuidBytes);
109+
var uuid2 = new Uuid(UuidBytes);
110+
var uuid3 = new Uuid(differentUuidBytes);
111+
112+
#pragma warning disable CS1718 // Comparison made to same variable
113+
#pragma warning disable NUnit2010 // Use Is.EqualTo constraint instead of direct comparison
114+
// ReSharper disable EqualExpressionComparison
115+
Assert.That(uuid1 == uuid1, Is.True);
116+
Assert.That(uuid1 != uuid1, Is.False);
117+
Assert.That(Uuid.Zero == Uuid.Zero, Is.True);
118+
Assert.That(Uuid.Zero != Uuid.Zero, Is.False);
119+
// ReSharper restore EqualExpressionComparison
120+
#pragma warning restore NUnit2010
121+
#pragma warning restore CS1718
122+
123+
Assert.That(uuid1 == uuid2, Is.True);
124+
Assert.That(uuid2 == uuid1, Is.True);
125+
Assert.That(uuid1 != uuid2, Is.False);
126+
Assert.That(uuid2 != uuid1, Is.False);
127+
128+
Assert.That(uuid1 == uuid3, Is.False);
129+
Assert.That(uuid3 == uuid1, Is.False);
130+
Assert.That(uuid1 != uuid3, Is.True);
131+
Assert.That(uuid3 != uuid1, Is.True);
132+
133+
Assert.That(uuid1.Equals(uuid2), Is.True);
134+
Assert.That(uuid2.Equals(uuid1), Is.True);
135+
Assert.That(uuid1.Equals(uuid3), Is.False);
136+
Assert.That(uuid3.Equals(uuid1), Is.False);
137+
138+
Assert.That(uuid1.Equals(null!), Is.False);
139+
Assert.That(uuid1.Equals(new object()), Is.False);
140+
}
141+
}

‎Vpn.Proto/packages.lock.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"requested": "[2.69.0, )",
1414
"resolved": "2.69.0",
1515
"contentHash": "W5hW4R1h19FCzKb8ToqIJMI5YxnQqGmREEpV8E5XkfCtLPIK5MSHztwQ8gZUfG8qu9fg5MhItjzyPRqQBjnrbA=="
16+
},
17+
"Coder.Desktop.CoderSdk": {
18+
"type": "Project"
1619
}
1720
}
1821
}

0 commit comments

Comments
 (0)
Please sign in to comment.