Skip to content

feat: Sign in Window views & view models #20

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ namespace Coder.Desktop.App;
public partial class App : Application
{
private readonly IServiceProvider _services;
private TrayWindow? _trayWindow;
private readonly bool _handleClosedEvents = true;

public App()
Expand All @@ -21,6 +20,11 @@ public App()
services.AddSingleton<ICredentialManager, CredentialManager>();
services.AddSingleton<IRpcController, RpcController>();

// SignInWindow views and view models
services.AddTransient<SignInViewModel>();
services.AddTransient<SignInWindow>();

// TrayWindow views and view models
services.AddTransient<TrayWindowDisconnectedViewModel>();
services.AddTransient<TrayWindowDisconnectedPage>();
services.AddTransient<TrayWindowLoginRequiredViewModel>();
Expand All @@ -42,14 +46,14 @@ public App()

protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_trayWindow = _services.GetRequiredService<TrayWindow>();
_trayWindow.Closed += (sender, args) =>
var trayWindow = _services.GetRequiredService<TrayWindow>();
trayWindow.Closed += (sender, args) =>
{
// TODO: wire up HandleClosedEvents properly
if (_handleClosedEvents)
{
args.Handled = true;
_trayWindow.AppWindow.Hide();
trayWindow.AppWindow.Hide();
}
};
}
Expand Down
26 changes: 26 additions & 0 deletions App/DisplayScale.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.UI.Xaml;
using WinRT.Interop;

namespace Coder.Desktop.App;

/// <summary>
/// A static utility class to house methods related to the visual scale of the display monitor.
/// </summary>
public static class DisplayScale
{
public static double WindowScale(Window win)
{
var hwnd = WindowNative.GetWindowHandle(win);
var dpi = NativeApi.GetDpiForWindow(hwnd);
if (dpi == 0) return 1; // assume scale of 1
return dpi / 96.0; // 96 DPI == 1
}

public class NativeApi
{
[DllImport("user32.dll")]
public static extern int GetDpiForWindow(IntPtr hwnd);
}
}
1 change: 1 addition & 0 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
try
{
var sdkClient = new CoderApiClient(uri);
sdkClient.SetSessionToken(apiToken);
// TODO: we should probably perform a version check here too,
// rather than letting the service do it on Start
_ = await sdkClient.GetBuildInfo(ct);
Expand Down
138 changes: 138 additions & 0 deletions App/ViewModels/SignInViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;

namespace Coder.Desktop.App.ViewModels;

/// <summary>
/// The View Model backing the sign in window and all its associated pages.
/// </summary>
public partial class SignInViewModel : ObservableObject
{
private readonly ICredentialManager _credentialManager;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CoderUrlError))]
[NotifyPropertyChangedFor(nameof(GenTokenUrl))]
public partial string CoderUrl { get; set; } = string.Empty;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CoderUrlError))]
public partial bool CoderUrlTouched { get; set; } = false;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
public partial string ApiToken { get; set; } = string.Empty;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
public partial bool ApiTokenTouched { get; set; } = false;

[ObservableProperty]
public partial string? SignInError { get; set; } = null;

[ObservableProperty]
public partial bool SignInLoading { get; set; } = false;

public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;

private string? _coderUrlError
{
get
{
if (!Uri.TryCreate(CoderUrl, UriKind.Absolute, out var uri))
return "Invalid URL";
if (uri.Scheme is not "http" and not "https")
return "Must be a HTTP or HTTPS URL";
if (uri.PathAndQuery != "/")
return "Must be a root URL with no path or query";
return null;
}
}

public string? ApiTokenError => ApiTokenTouched ? _apiTokenError : null;

private string? _apiTokenError => string.IsNullOrWhiteSpace(ApiToken) ? "Invalid token" : null;

public Uri GenTokenUrl
{
get
{
// In case somehow the URL is invalid, just default to coder.com.
// The HyperlinkButton will crash the entire app if the URL is
// invalid.
try
{
var baseUri = new Uri(CoderUrl.Trim());
var cliAuthUri = new Uri(baseUri, "/cli-auth");
return cliAuthUri;
}
catch
{
return new Uri("https://coder.com");
}
}
}

public SignInViewModel(ICredentialManager credentialManager)
{
_credentialManager = credentialManager;
}

public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
{
CoderUrlTouched = true;
}

public void ApiToken_FocusLost(object sender, RoutedEventArgs e)
{
ApiTokenTouched = true;
}

[RelayCommand]
public void UrlPage_Next(SignInWindow signInWindow)
{
CoderUrlTouched = true;
if (_coderUrlError != null) return;
signInWindow.NavigateToTokenPage();
}

[RelayCommand]
public void TokenPage_Back(SignInWindow signInWindow)
{
ApiToken = "";
signInWindow.NavigateToUrlPage();
}

[RelayCommand]
public async Task TokenPage_SignIn(SignInWindow signInWindow)
{
CoderUrlTouched = true;
ApiTokenTouched = true;
if (_coderUrlError != null || _apiTokenError != null) return;

try
{
SignInLoading = true;
SignInError = null;

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);

signInWindow.Close();
}
catch (Exception e)
{
SignInError = $"Failed to sign in: {e}";
}
finally
{
SignInLoading = false;
}
}
}
27 changes: 24 additions & 3 deletions App/ViewModels/TrayWindowLoginRequiredViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System;
using Coder.Desktop.App.Views;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;

namespace Coder.Desktop.App.ViewModels;

public partial class TrayWindowLoginRequiredViewModel : ObservableObject
public partial class TrayWindowLoginRequiredViewModel
{
private readonly IServiceProvider _services;

private SignInWindow? _signInWindow;

public TrayWindowLoginRequiredViewModel(IServiceProvider services)
{
_services = services;
}

[RelayCommand]
public void Login()
{
// TODO: open the login window
// This is safe against concurrent access since it all happens in the
// UI thread.
if (_signInWindow != null)
{
_signInWindow.Activate();
return;
}

_signInWindow = _services.GetRequiredService<SignInWindow>();
_signInWindow.Closed += (_, _) => _signInWindow = null;
_signInWindow.Activate();
}
}
104 changes: 104 additions & 0 deletions App/Views/Pages/SignInTokenPage.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>

<Page
x:Class="Coder.Desktop.App.Views.Pages.SignInTokenPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">

<StackPanel
Orientation="Vertical"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Padding="20"
Spacing="10">

<TextBlock
Text="Coder Desktop"
FontSize="24"
VerticalAlignment="Center"
HorizontalAlignment="Center" />

<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="1*" />
<RowDefinition Height="1*" />
<RowDefinition Height="1*" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>

<TextBlock
Grid.Column="0"
Grid.Row="0"
Text="Server URL"
HorizontalAlignment="Right"
Padding="10" />

<TextBlock
Grid.Column="1"
Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Padding="10"
Text="{x:Bind ViewModel.CoderUrl, Mode=OneWay}" />

<TextBlock
Grid.Column="0"
Grid.Row="2"
Text="Session Token"
HorizontalAlignment="Right"
Padding="10" />

<TextBox
Grid.Column="1"
Grid.Row="2"
HorizontalAlignment="Stretch"
PlaceholderText="Paste your token here"
LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}"
Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" />

<TextBlock
Grid.Column="1"
Grid.Row="3"
Text="{x:Bind ViewModel.ApiTokenError, Mode=OneWay}"
Foreground="Red" />

<HyperlinkButton
Grid.Column="1"
Grid.Row="4"
Content="Generate a token via the Web UI"
NavigateUri="{x:Bind ViewModel.GenTokenUrl, Mode=OneWay}" />
</Grid>

<StackPanel
Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="10">

<Button
Content="Back" HorizontalAlignment="Right"
Command="{x:Bind ViewModel.TokenPage_BackCommand, Mode=OneWay}"
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />

<Button
Content="Sign In"
HorizontalAlignment="Left"
Style="{StaticResource AccentButtonStyle}"
Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
</StackPanel>

<TextBlock
Text="{x:Bind ViewModel.SignInError, Mode=OneWay}"
HorizontalAlignment="Center"
Foreground="Red" />
</StackPanel>
</Page>
20 changes: 20 additions & 0 deletions App/Views/Pages/SignInTokenPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Coder.Desktop.App.ViewModels;
using Microsoft.UI.Xaml.Controls;

namespace Coder.Desktop.App.Views.Pages;

/// <summary>
/// A sign in page to accept the user's Coder token.
/// </summary>
public sealed partial class SignInTokenPage : Page
{
public readonly SignInViewModel ViewModel;
public readonly SignInWindow SignInWindow;

public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel)
{
InitializeComponent();
ViewModel = viewModel;
SignInWindow = parent;
}
}
Loading
Loading