Initial project snapshot

This commit is contained in:
2026-04-28 22:29:50 +03:00
commit 8ba0561f4f
365 changed files with 91832 additions and 0 deletions
@@ -0,0 +1,10 @@
<Application x:Class="RemoteAccessPlatform.Windows.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:RemoteAccessPlatform.Windows.App"
xmlns:converters="clr-namespace:RemoteAccessPlatform.Windows.App.Converters">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<converters:InverseBooleanToVisibilityConverter x:Key="InverseBooleanToVisibilityConverter" />
</Application.Resources>
</Application>
@@ -0,0 +1,46 @@
using System.Windows;
using RemoteAccessPlatform.Windows.App.Bootstrap;
using RemoteAccessPlatform.Windows.App.Diagnostics;
namespace RemoteAccessPlatform.Windows.App;
public partial class App : System.Windows.Application
{
private ClientRuntime? _runtime;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ClientTrace.EnsureInitialized();
ClientTrace.Write("App startup");
DispatcherUnhandledException += (_, args) =>
{
ClientTrace.Write($"DispatcherUnhandledException: {args.Exception}");
};
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
ClientTrace.Write($"AppDomainUnhandledException: {args.ExceptionObject}");
};
TaskScheduler.UnobservedTaskException += (_, args) =>
{
ClientTrace.Write($"TaskScheduler.UnobservedTaskException: {args.Exception}");
};
_runtime = ClientRuntime.Create();
ClientTrace.Write("ClientRuntime created");
var mainWindow = new MainWindow(_runtime.MainViewModel);
MainWindow = mainWindow;
ClientTrace.Write("MainWindow constructed");
mainWindow.Show();
ClientTrace.Write($"MainWindow shown. Title={mainWindow.Title}, IsVisible={mainWindow.IsVisible}");
}
protected override void OnExit(ExitEventArgs e)
{
ClientTrace.Write("App exit");
_runtime?.Dispose();
base.OnExit(e);
}
}
@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
@@ -0,0 +1,107 @@
using System.Net.Http;
using System.IO;
using System.Text.Json;
using RemoteAccessPlatform.Windows.Application.Services;
using RemoteAccessPlatform.Windows.Application.ViewModels;
using RemoteAccessPlatform.Windows.Models;
using RemoteAccessPlatform.Windows.Settings;
using RemoteAccessPlatform.Windows.Transport;
namespace RemoteAccessPlatform.Windows.App.Bootstrap;
public sealed class ClientRuntime : IDisposable
{
public ClientRuntime(
HttpClient httpClient,
AuthenticationService authenticationService,
MainViewModel mainViewModel)
{
HttpClient = httpClient;
AuthenticationService = authenticationService;
MainViewModel = mainViewModel;
}
public HttpClient HttpClient { get; }
public AuthenticationService AuthenticationService { get; }
public MainViewModel MainViewModel { get; }
public static ClientRuntime Create()
{
BackendEndpointOptions endpointOptions = LoadEndpointOptions();
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
var tokenStore = new DpapiTokenStore();
var localSettingsStore = new JsonLocalSettingsStore();
var deviceIdentityProvider = new LocalDeviceIdentityProvider(localSettingsStore);
AuthenticationService? authenticationService = null;
var apiClient = new BackendApiClient(
httpClient,
endpointOptions,
cancellationToken => authenticationService!.GetAccessTokenAsync(cancellationToken),
cancellationToken => authenticationService!.TryRefreshAsync(cancellationToken));
authenticationService = new AuthenticationService(apiClient, tokenStore, deviceIdentityProvider);
var organizationContextService = new OrganizationContextService(apiClient, localSettingsStore);
var resourceCatalogService = new ResourceCatalogService(apiClient);
var sessionService = new SessionService(apiClient);
var sessionGatewayClient = new SessionGatewayClient(endpointOptions);
var mainViewModel = new MainViewModel(
authenticationService,
organizationContextService,
resourceCatalogService,
sessionService,
sessionGatewayClient);
return new ClientRuntime(httpClient, authenticationService, mainViewModel);
}
public void Dispose()
{
HttpClient.Dispose();
}
private static BackendEndpointOptions LoadEndpointOptions()
{
string path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path))
{
return new BackendEndpointOptions();
}
using FileStream stream = File.OpenRead(path);
using JsonDocument document = JsonDocument.Parse(stream);
if (!document.RootElement.TryGetProperty("backend", out JsonElement backend))
{
return new BackendEndpointOptions();
}
return new BackendEndpointOptions
{
ApiBaseUrl = backend.TryGetProperty("api_base_url", out JsonElement apiBaseUrl)
? apiBaseUrl.GetString() ?? "http://192.168.200.61:8080/api/v1"
: "http://192.168.200.61:8080/api/v1",
GatewayWebSocketUrl = backend.TryGetProperty("gateway_websocket_url", out JsonElement gatewayUrl)
? gatewayUrl.GetString() ?? "ws://192.168.200.61:8080/api/v1/gateway/ws"
: "ws://192.168.200.61:8080/api/v1/gateway/ws",
PreferDirectDataPlane = !backend.TryGetProperty("prefer_direct_data_plane", out JsonElement preferDirect) || preferDirect.GetBoolean(),
DirectDataPlaneConnectTimeoutMs = backend.TryGetProperty("direct_data_plane_connect_timeout_ms", out JsonElement directTimeout)
? Math.Max(100, directTimeout.GetInt32())
: 750,
AllowInsecureDirectDataPlaneTlsForSmoke = backend.TryGetProperty("allow_insecure_direct_data_plane_tls_for_smoke", out JsonElement insecureDirectTls) && insecureDirectTls.GetBoolean(),
DirectDataPlaneColorMode = backend.TryGetProperty("direct_data_plane_color_mode", out JsonElement colorMode)
? colorMode.GetString() ?? "full_color"
: "full_color",
DirectDataPlanePlatformCaBundle = backend.TryGetProperty("direct_data_plane_platform_ca_bundle", out JsonElement platformCaBundle)
? platformCaBundle.GetString()
: null,
Environment = backend.TryGetProperty("environment", out JsonElement environment)
? environment.GetString() ?? "development"
: "development"
};
}
}
@@ -0,0 +1,19 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace RemoteAccessPlatform.Windows.App.Converters;
public sealed class InverseBooleanToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
bool visible = value is bool boolValue && !boolValue;
return visible ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is Visibility visibility && visibility != Visibility.Visible;
}
}
@@ -0,0 +1,56 @@
using System.Diagnostics;
using System.IO;
namespace RemoteAccessPlatform.Windows.App.Diagnostics;
internal static class ClientTrace
{
private static readonly object Sync = new();
private static bool _initialized;
private static string? _logPath;
public static string LogPath
{
get
{
EnsureInitialized();
return _logPath!;
}
}
public static void EnsureInitialized()
{
lock (Sync)
{
if (_initialized)
{
return;
}
string root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RemoteAccessPlatform",
"WindowsClient",
"logs");
Directory.CreateDirectory(root);
_logPath = Path.Combine(root, "desktop-client.log");
var stream = new FileStream(_logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
var listener = new TextWriterTraceListener(stream)
{
TraceOutputOptions = TraceOptions.DateTime
};
Trace.AutoFlush = true;
Trace.Listeners.Add(listener);
Trace.WriteLine($"[{DateTimeOffset.Now:O}] Trace initialized. PID={Environment.ProcessId}");
_initialized = true;
}
}
public static void Write(string message)
{
EnsureInitialized();
Trace.WriteLine($"[{DateTimeOffset.Now:O}] [T{Environment.CurrentManagedThreadId}] {message}");
}
}
@@ -0,0 +1,129 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.App.Input;
internal static class SessionInputMapper
{
private const uint MapVkToScEx = 4;
public static SessionInputEventDto? CreateKeyboardEvent(KeyEventArgs args, bool isKeyDown)
{
Key key = args.Key == Key.System ? args.SystemKey : args.Key;
if (key is Key.None or Key.ImeProcessed or Key.DeadCharProcessed)
{
return null;
}
int virtualKey = KeyInterop.VirtualKeyFromKey(key);
if (virtualKey <= 0)
{
return null;
}
uint scanCode = MapVirtualKey((uint)virtualKey, MapVkToScEx);
if (scanCode == 0)
{
return null;
}
return new SessionInputEventDto
{
Kind = "keyboard",
Action = isKeyDown ? "key_down" : "key_up",
ScanCode = scanCode & 0xFF,
IsExtended = IsExtendedKey(virtualKey)
};
}
public static SessionInputEventDto CreateFocusEvent(bool focused)
{
return new SessionInputEventDto
{
Kind = "focus",
Action = focused ? "focus_in" : "focus_out",
Focused = focused
};
}
public static SessionInputEventDto CreateMouseMoveEvent(Point position, Size surfaceSize)
{
(double x, double y) = Normalize(position, surfaceSize);
return new SessionInputEventDto
{
Kind = "mouse",
Action = "move",
NormalizedX = x,
NormalizedY = y,
SurfaceWidth = surfaceSize.Width,
SurfaceHeight = surfaceSize.Height
};
}
public static SessionInputEventDto CreateMouseButtonEvent(string button, bool pressed, Point position, Size surfaceSize)
{
(double x, double y) = Normalize(position, surfaceSize);
return new SessionInputEventDto
{
Kind = "mouse",
Action = pressed ? "button_down" : "button_up",
Button = button,
NormalizedX = x,
NormalizedY = y,
SurfaceWidth = surfaceSize.Width,
SurfaceHeight = surfaceSize.Height
};
}
public static SessionInputEventDto CreateMouseWheelEvent(int wheelDelta, bool horizontal, Point position, Size surfaceSize)
{
(double x, double y) = Normalize(position, surfaceSize);
return new SessionInputEventDto
{
Kind = "mouse",
Action = "wheel",
WheelDelta = wheelDelta,
IsHorizontalWheel = horizontal,
NormalizedX = x,
NormalizedY = y,
SurfaceWidth = surfaceSize.Width,
SurfaceHeight = surfaceSize.Height
};
}
private static (double X, double Y) Normalize(Point position, Size surfaceSize)
{
double width = Math.Max(1d, surfaceSize.Width);
double height = Math.Max(1d, surfaceSize.Height);
double x = Math.Clamp(position.X / width, 0d, 1d);
double y = Math.Clamp(position.Y / height, 0d, 1d);
return (x, y);
}
private static bool IsExtendedKey(int virtualKey)
{
return virtualKey switch
{
0x21 or // PAGE UP
0x22 or // PAGE DOWN
0x23 or // END
0x24 or // HOME
0x25 or // LEFT
0x26 or // UP
0x27 or // RIGHT
0x28 or // DOWN
0x2D or // INSERT
0x2E or // DELETE
0x6F or // NUMPAD DIVIDE
0x90 or // NUMLOCK
0xA3 or // RIGHT CTRL
0xA5 => true, // RIGHT ALT
_ => false
};
}
[DllImport("user32.dll", EntryPoint = "MapVirtualKeyW", ExactSpelling = true)]
private static extern uint MapVirtualKey(uint code, uint mapType);
}
@@ -0,0 +1,141 @@
<Window x:Class="RemoteAccessPlatform.Windows.App.MainWindow"
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"
xmlns:views="clr-namespace:RemoteAccessPlatform.Windows.App.Views"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d"
Title="{x:Static appres:Strings.MainWindowTitle}"
Height="860"
Width="1380"
MinHeight="720"
MinWidth="1180"
WindowState="Maximized"
Background="#FFF1F3F6">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Margin="0,0,0,16"
Padding="18"
Background="#FF0E3B43"
CornerRadius="14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Foreground="White"
FontSize="28"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.MainHeaderTitle}" />
<TextBlock Margin="0,4,0,0"
Foreground="#FFC7DBDE"
AutomationProperties.AutomationId="MainStatusText"
Text="{Binding StatusMessage}" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock Foreground="White"
FontWeight="SemiBold"
AutomationProperties.AutomationId="CurrentUserText"
Text="{Binding CurrentUserEmail}" />
<Button Margin="0,10,0,0"
Padding="14,6"
AutomationProperties.AutomationId="LogoutButton"
AutomationProperties.Name="{x:Static appres:Strings.MainLogout}"
Command="{Binding LogoutCommand}"
Content="{x:Static appres:Strings.MainLogout}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1">
<Grid Visibility="{Binding IsAuthenticated, Converter={StaticResource InverseBooleanToVisibilityConverter}}">
<views:LoginView HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
<Grid Visibility="{Binding IsAuthenticated, Converter={StaticResource BooleanToVisibilityConverter}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<views:OrganizationSwitchView Grid.Row="0"
Margin="0,0,0,16" />
<Border Grid.Row="1"
Margin="0,0,0,16"
Padding="16"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<StackPanel Orientation="Horizontal">
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="StartSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainStartSession}"
Command="{Binding StartSessionCommand}"
CommandParameter="{Binding SelectedResource}"
Content="{x:Static appres:Strings.MainStartSession}" />
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="AttachSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainAttach}"
Command="{Binding AttachSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainAttach}" />
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="TakeOverSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainTakeOver}"
Command="{Binding TakeOverSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainTakeOver}" />
<Button Padding="14,8"
AutomationProperties.AutomationId="TerminateSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainTerminate}"
Command="{Binding TerminateSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainTerminate}" />
</StackPanel>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<views:ResourceListView Grid.Column="0"
Margin="0,0,10,0" />
<views:ActiveSessionsView Grid.Column="1"
Margin="10,0,0,0" />
</Grid>
</Grid>
</Grid>
<Border Grid.Row="2"
Margin="0,16,0,0"
Padding="12"
Background="#FFF8FBFC"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="10">
<TextBlock Foreground="#FF4C535F"
AutomationProperties.AutomationId="FooterStatusText"
Text="{Binding StatusMessage}" />
</Border>
</Grid>
</Window>
@@ -0,0 +1,65 @@
using System.Windows;
using RemoteAccessPlatform.Windows.Application.ViewModels;
using RemoteAccessPlatform.Windows.App.Diagnostics;
namespace RemoteAccessPlatform.Windows.App;
public partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
ClientTrace.Write("MainWindow InitializeComponent completed");
_viewModel = viewModel;
DataContext = viewModel;
ClientTrace.Write($"MainWindow DataContext assigned: {viewModel.GetType().FullName}");
Loaded += OnLoaded;
Closed += OnClosed;
_viewModel.SessionWindowRequested += HandleSessionWindowRequested;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
Loaded -= OnLoaded;
ClientTrace.Write("MainWindow loaded");
await _viewModel.InitializeAsync();
ClientTrace.Write("MainWindow InitializeAsync completed");
}
private void OnClosed(object? sender, EventArgs e)
{
ClientTrace.Write("MainWindow closed");
_viewModel.SessionWindowRequested -= HandleSessionWindowRequested;
}
private async void HandleSessionWindowRequested(SessionWindowViewModel viewModel)
{
ClientTrace.Write($"SessionWindowRequested received. Dispatcher.CheckAccess={Dispatcher.CheckAccess()}, Title={viewModel.Title}");
await Dispatcher.InvokeAsync(() =>
{
ClientTrace.Write("Creating SessionWindow on dispatcher");
var canOwnWindow = IsVisible && WindowState != WindowState.Minimized;
var window = new SessionWindow(viewModel)
{
Owner = canOwnWindow ? this : null,
WindowStartupLocation = canOwnWindow ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen,
ShowActivated = true,
WindowState = WindowState.Maximized
};
ClientTrace.Write($"SessionWindow created. CanOwnWindow={canOwnWindow}, OwnerVisible={IsVisible}, OwnerState={WindowState}, DataContext={window.DataContext?.GetType().FullName}");
window.Show();
window.Activate();
if (window.WindowState == WindowState.Minimized)
{
window.WindowState = WindowState.Maximized;
}
ClientTrace.Write($"SessionWindow shown and activated. Title={window.Title}, IsVisible={window.IsVisible}, WindowState={window.WindowState}, Left={window.Left}, Top={window.Top}");
});
ClientTrace.Write("Calling SessionWindowViewModel.InitializeAsync");
await viewModel.InitializeAsync(CancellationToken.None);
ClientTrace.Write("SessionWindowViewModel.InitializeAsync completed");
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Application\RemoteAccessPlatform.Windows.Application.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Contracts\RemoteAccessPlatform.Windows.Contracts.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Transport\RemoteAccessPlatform.Windows.Transport.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Settings\RemoteAccessPlatform.Windows.Settings.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,164 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.App.Rendering;
internal sealed class DesktopFramePresenter
{
private WriteableBitmap? _desktopBitmap;
private byte[]? _framebuffer;
private int _framebufferStride;
public static PreparedDesktopFrame? Prepare(SessionFrameDto? frame, CancellationToken cancellationToken)
{
if (frame is null || frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length == 0)
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
int stride = frame.Stride > 0 ? frame.Stride : frame.Width * 4;
bool isRegion = string.Equals(frame.UpdateKind, "region", StringComparison.OrdinalIgnoreCase);
int desktopWidth = frame.DesktopWidth > 0 ? frame.DesktopWidth : frame.Width;
int desktopHeight = frame.DesktopHeight > 0 ? frame.DesktopHeight : frame.Height;
int regionWidth = frame.RegionWidth > 0 ? frame.RegionWidth : frame.Width;
int regionHeight = frame.RegionHeight > 0 ? frame.RegionHeight : frame.Height;
int regionX = isRegion ? frame.RegionX : 0;
int regionY = isRegion ? frame.RegionY : 0;
if (isRegion && frame.RegionStride > 0)
{
stride = frame.RegionStride;
}
int minimumBytes = stride * regionHeight;
if (stride < regionWidth * 4 || frame.Pixels.Length < minimumBytes)
{
return null;
}
return new PreparedDesktopFrame(
frame.FrameSequence,
desktopWidth,
desktopHeight,
frame.InputCorrelationId,
frame.WorkerFrameCapturedAt,
frame.ColorMode,
isRegion,
regionX,
regionY,
regionWidth,
regionHeight,
stride,
frame.Pixels);
}
public bool Present(Image image, FrameworkElement overlay, PreparedDesktopFrame? frame)
{
if (image is null || overlay is null || frame is null)
{
return false;
}
if (frame.Width <= 0 || frame.Height <= 0)
{
return false;
}
if (frame.IsRegion && _desktopBitmap is null)
{
return false;
}
if (_desktopBitmap is null ||
_desktopBitmap.PixelWidth != frame.Width ||
_desktopBitmap.PixelHeight != frame.Height)
{
if (frame.IsRegion)
{
return false;
}
_desktopBitmap = new WriteableBitmap(
frame.Width,
frame.Height,
96,
96,
PixelFormats.Bgra32,
null);
image.Source = _desktopBitmap;
_framebufferStride = frame.Width * 4;
_framebuffer = new byte[_framebufferStride * frame.Height];
}
var rect = frame.IsRegion
? new Int32Rect(frame.RegionX, frame.RegionY, frame.RegionWidth, frame.RegionHeight)
: new Int32Rect(0, 0, frame.Width, frame.Height);
if (rect.X < 0 ||
rect.Y < 0 ||
rect.Width <= 0 ||
rect.Height <= 0 ||
rect.X + rect.Width > frame.Width ||
rect.Y + rect.Height > frame.Height ||
frame.Stride < rect.Width * 4 ||
frame.Pixels.Length < frame.Stride * rect.Height)
{
return false;
}
if (_framebuffer is null || _framebufferStride != frame.Width * 4)
{
return false;
}
if (frame.IsRegion)
{
for (int row = 0; row < rect.Height; ++row)
{
int sourceOffset = row * frame.Stride;
int targetOffset = (rect.Y + row) * _framebufferStride + rect.X * 4;
Buffer.BlockCopy(frame.Pixels, sourceOffset, _framebuffer, targetOffset, rect.Width * 4);
}
}
else if (frame.Stride == _framebufferStride)
{
Buffer.BlockCopy(frame.Pixels, 0, _framebuffer, 0, _framebuffer.Length);
}
else
{
for (int row = 0; row < frame.Height; ++row)
{
Buffer.BlockCopy(frame.Pixels, row * frame.Stride, _framebuffer, row * _framebufferStride, _framebufferStride);
}
}
_desktopBitmap.WritePixels(rect, frame.Pixels, frame.Stride, 0);
overlay.Visibility = Visibility.Collapsed;
return true;
}
public void Reset(Image image, FrameworkElement overlay)
{
_desktopBitmap = null;
_framebuffer = null;
_framebufferStride = 0;
image.Source = null;
overlay.Visibility = Visibility.Visible;
}
}
internal sealed record PreparedDesktopFrame(
long FrameSequence,
int Width,
int Height,
string? InputCorrelationId,
string? WorkerFrameCapturedAt,
string ColorMode,
bool IsRegion,
int RegionX,
int RegionY,
int RegionWidth,
int RegionHeight,
int Stride,
byte[] Pixels);
@@ -0,0 +1,298 @@
<Window x:Class="RemoteAccessPlatform.Windows.App.SessionWindow"
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"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d"
Height="560"
Width="860"
MinHeight="480"
MinWidth="760"
Background="#FFF4F6F8"
ShowInTaskbar="True"
WindowState="Maximized"
Title="{Binding Title}">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Padding="16"
Background="#FF143C53"
CornerRadius="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock FontSize="24"
FontWeight="SemiBold"
Foreground="White"
AutomationProperties.AutomationId="SessionWindowSessionIdText"
Text="{Binding SessionId}" />
<TextBlock Margin="0,6,0,0"
Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowStateText"
Text="{Binding SessionStateDisplay}" />
<TextBlock Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowConnectionStatusText"
Text="{Binding ConnectionStatusDisplay}" />
<TextBlock Margin="0,6,0,0"
Foreground="White"
FontWeight="SemiBold"
AutomationProperties.AutomationId="SessionWindowShellStateText"
Text="{Binding ShellStateDisplay}" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Top">
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowReconnectButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowReconnect}"
Command="{Binding ReconnectCommand}"
Content="{x:Static appres:Strings.SessionWindowReconnect}" />
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowDetachButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowDetach}"
Command="{Binding DetachCommand}"
Content="{x:Static appres:Strings.SessionWindowDetach}" />
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTakeOverButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTakeOver}"
Command="{Binding TakeOverThisDeviceCommand}"
Content="{x:Static appres:Strings.SessionWindowTakeOver}" />
<Button Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTerminateButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTerminate}"
Command="{Binding TerminateCommand}"
Content="{x:Static appres:Strings.SessionWindowTerminate}" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1"
Padding="12"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="18" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Background="#FF12202A"
BorderBrush="#FF244456"
BorderThickness="1"
CornerRadius="10">
<Border x:Name="SessionSurface"
Padding="0"
Background="Transparent"
Focusable="True"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
AutomationProperties.AutomationId="SessionWindowSurface"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowSurfaceTitle}"
GotKeyboardFocus="OnSessionSurfaceGotKeyboardFocus"
LostKeyboardFocus="OnSessionSurfaceLostKeyboardFocus"
PreviewKeyDown="OnSessionSurfacePreviewKeyDown"
PreviewKeyUp="OnSessionSurfacePreviewKeyUp"
PreviewMouseMove="OnSessionSurfacePreviewMouseMove"
PreviewMouseDown="OnSessionSurfacePreviewMouseDown"
PreviewMouseUp="OnSessionSurfacePreviewMouseUp"
PreviewMouseWheel="OnSessionSurfacePreviewMouseWheel">
<Grid>
<Image x:Name="SessionFrameImage"
Stretch="Uniform"
IsHitTestVisible="False"
RenderOptions.BitmapScalingMode="LowQuality"
SnapsToDevicePixels="True" />
<Border HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Margin="12"
Padding="8,4"
Background="#AA10212C"
CornerRadius="8">
<TextBlock Foreground="#FF9FD4F1"
AutomationProperties.AutomationId="SessionWindowInputStatusText"
Text="{Binding InputStatusDisplay}" />
</Border>
<StackPanel x:Name="SessionSurfaceOverlay"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Width="320">
<TextBlock HorizontalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Foreground="White"
Text="{x:Static appres:Strings.SessionWindowSurfaceTitle}" />
<TextBlock Margin="0,10,0,0"
TextAlignment="Center"
Foreground="#FFD1E1ED"
Text="{x:Static appres:Strings.SessionWindowSurfaceSubtitle}" />
</StackPanel>
</Grid>
</Border>
</Border>
<ScrollViewer Grid.Column="2"
Focusable="False"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<Expander IsExpanded="False"
Focusable="False"
IsTabStop="False">
<Expander.Header>
<TextBlock FontWeight="SemiBold"
Text="{Binding SessionSummary}" />
</Expander.Header>
<StackPanel Margin="0,10,0,0">
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowSummaryText"
Text="{Binding SessionSummary}" />
<TextBlock Margin="0,6,0,0"
Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowActionSummaryText"
Text="{Binding ActionSummary}" />
<TextBlock Margin="0,8,0,0"
Foreground="#FF56606B"
Text="{x:Static appres:Strings.SessionWindowIntro}" />
</StackPanel>
</Expander>
<Expander Margin="0,12,0,0"
IsExpanded="False"
Focusable="False"
IsTabStop="False">
<Expander.Header>
<TextBlock FontWeight="SemiBold"
Text="{x:Static appres:Strings.SessionWindowRenderHeading}" />
</Expander.Header>
<StackPanel Margin="0,10,0,0">
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowRenderProfileText"
Text="{Binding RenderProfileDisplay}" />
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowRenderStateText"
Text="{Binding RenderStateDisplay}" />
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowRenderSizeText"
Text="{Binding RenderSizeDisplay}" />
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowCursorStatusText"
Text="{Binding CursorStatusDisplay}" />
<TextBlock Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowRenderTelemetryText"
Text="{Binding RenderTelemetryDisplay}" />
<TextBlock Margin="0,8,0,0"
Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowClipboardStatusText"
Text="{Binding ClipboardStatusDisplay}" />
<StackPanel Margin="0,8,0,0"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Padding="10,5"
AutomationProperties.AutomationId="SessionWindowClipboardSendButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowClipboardSend}"
Click="OnSendClipboardToServerClick"
Content="{x:Static appres:Strings.SessionWindowClipboardSend}" />
<Button Padding="10,5"
AutomationProperties.AutomationId="SessionWindowClipboardReceiveButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowClipboardReceive}"
Click="OnReceiveClipboardFromServerClick"
Content="{x:Static appres:Strings.SessionWindowClipboardReceive}" />
</StackPanel>
<TextBlock Margin="0,12,0,0"
Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowFileUploadStatusText"
Text="{Binding FileUploadStatusDisplay}" />
<ProgressBar Margin="0,6,0,0"
Height="8"
Minimum="0"
Maximum="100"
Value="{Binding FileUploadProgress, Mode=OneWay}"
AutomationProperties.AutomationId="SessionWindowFileUploadProgress" />
<StackPanel Margin="0,8,0,0"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Padding="10,5"
AutomationProperties.AutomationId="SessionWindowFileUploadButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowFileUpload}"
Click="OnUploadFileToServerClick"
Content="{x:Static appres:Strings.SessionWindowFileUpload}" />
<Button Padding="10,5"
AutomationProperties.AutomationId="SessionWindowFileUploadCancelButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowFileUploadCancel}"
Click="OnCancelFileUploadClick"
Content="{x:Static appres:Strings.SessionWindowFileUploadCancel}" />
</StackPanel>
<TextBlock Margin="0,12,0,0"
Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowFileDownloadStatusText"
Text="{Binding FileDownloadStatusDisplay}" />
<TextBlock Margin="0,4,0,0"
Foreground="#FF56606B"
AutomationProperties.AutomationId="SessionWindowFileDownloadAvailableText"
Text="{Binding FileDownloadActionDisplay}" />
<ProgressBar Margin="0,6,0,0"
Height="8"
Minimum="0"
Maximum="100"
Value="{Binding FileDownloadProgress, Mode=OneWay}"
AutomationProperties.AutomationId="SessionWindowFileDownloadProgress" />
<StackPanel Margin="0,8,0,0"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Padding="10,5"
AutomationProperties.AutomationId="SessionWindowFileDownloadButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowFileDownload}"
Click="OnDownloadFileFromServerClick"
Content="{x:Static appres:Strings.SessionWindowFileDownload}" />
<Button Padding="10,5"
AutomationProperties.AutomationId="SessionWindowFileDownloadCancelButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowFileDownloadCancel}"
Click="OnCancelFileDownloadClick"
Content="{x:Static appres:Strings.SessionWindowFileDownloadCancel}" />
</StackPanel>
</StackPanel>
</Expander>
<Expander Margin="0,12,0,0"
IsExpanded="True"
Focusable="False"
IsTabStop="False">
<Expander.Header>
<TextBlock FontWeight="SemiBold"
Text="{x:Static appres:Strings.SessionWindowEventLog}" />
</Expander.Header>
<ListBox Margin="0,10,0,0"
MinHeight="220"
Focusable="False"
IsTabStop="False"
ItemsSource="{Binding EventLog}"
AutomationProperties.AutomationId="SessionWindowEventLog"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowEventLog}" />
</Expander>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Grid>
</Window>
@@ -0,0 +1,986 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using System.Windows.Controls;
using Microsoft.Win32;
using RemoteAccessPlatform.Windows.Application.ViewModels;
using RemoteAccessPlatform.Windows.App.Diagnostics;
using RemoteAccessPlatform.Windows.App.Input;
using RemoteAccessPlatform.Windows.App.Rendering;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.App;
public partial class SessionWindow : Window
{
private static readonly TimeSpan MouseMoveSendInterval = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan MouseMoveRateLogInterval = TimeSpan.FromSeconds(2);
private const int MaxOrderedFrameBacklog = 256;
private readonly SessionWindowViewModel _viewModel;
private readonly DesktopFramePresenter _framePresenter = new();
private readonly CancellationTokenSource _windowCloseCancellation = new();
private readonly object _mouseMoveSync = new();
private readonly object _framePreparationSync = new();
private Point _lastMousePosition;
private DateTimeOffset _lastMouseMoveAt = DateTimeOffset.MinValue;
private bool _keyboardObserved;
private bool _mouseObserved;
private long _lastRenderedFrameSequence = -1;
private readonly Queue<SessionFrameDto> _pendingFrames = new();
private bool _framePreparationRunning;
private bool _inputCaptured;
private SessionInputEventDto? _pendingMouseMoveInput;
private bool _mouseMovePumpRunning;
private readonly ConcurrentQueue<SessionInputEventDto> _inputQueue = new();
private int _inputPumpRunning;
private bool _remoteFocusPrimed;
private DateTimeOffset _lastMouseMoveSentAt = DateTimeOffset.MinValue;
private DateTimeOffset _lastMouseMoveRateLogAt = DateTimeOffset.UtcNow;
private int _mouseMoveCapturedSinceLog;
private int _mouseMoveSentSinceLog;
private int _mouseMoveCoalescedSinceLog;
private double _lastMouseMoveLogX;
private double _lastMouseMoveLogY;
private bool _isClosing;
public SessionWindow(SessionWindowViewModel viewModel)
{
InitializeComponent();
ClientTrace.Write("SessionWindow InitializeComponent completed");
_viewModel = viewModel;
DataContext = viewModel;
ClientTrace.Write($"SessionWindow DataContext assigned: {viewModel.GetType().FullName}, Title={viewModel.Title}");
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
Closing += OnClosing;
Closed += OnClosed;
SourceInitialized += (_, _) => ClientTrace.Write($"SessionWindow SourceInitialized. Title={Title}");
Loaded += OnLoaded;
ContentRendered += (_, _) => ClientTrace.Write($"SessionWindow ContentRendered. Title={Title}, IsVisible={IsVisible}");
Activated += OnActivated;
Deactivated += OnDeactivated;
IsVisibleChanged += (_, _) => ClientTrace.Write($"SessionWindow IsVisibleChanged. Title={Title}, IsVisible={IsVisible}");
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
ClientTrace.Write($"SessionWindow Loaded. Title={Title}, IsVisible={IsVisible}");
UpdateFrameSurface(null);
BeginSurfaceFocus("loaded");
}
private void OnActivated(object? sender, EventArgs e)
{
ClientTrace.Write($"SessionWindow Activated. Title={Title}");
BeginSurfaceFocus("activated");
}
private async void OnDeactivated(object? sender, EventArgs e)
{
ClientTrace.Write($"SessionWindow Deactivated. Title={Title}, InputCaptured={_inputCaptured}");
_remoteFocusPrimed = false;
ReleaseManualInputCapture("window_deactivated");
if (_isClosing)
{
return;
}
await _viewModel.HandleFocusChangedAsync(false);
}
private void BeginSurfaceFocus(string reason)
{
_ = Dispatcher.InvokeAsync(() =>
{
if (SessionSurface.IsKeyboardFocusWithin)
{
return;
}
bool focused = SessionSurface.Focus();
Keyboard.Focus(SessionSurface);
ClientTrace.Write($"SessionWindow requested surface focus on {reason}. FocusResult={focused}, IsKeyboardFocusWithin={SessionSurface.IsKeyboardFocusWithin}, InputCaptured={_inputCaptured}");
}, DispatcherPriority.Input);
}
private async void OnSessionSurfaceGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
ClientTrace.Write($"SessionWindow surface got keyboard focus. NewFocus={e.NewFocus?.GetType().Name}, OldFocus={e.OldFocus?.GetType().Name}, InputCaptured={_inputCaptured}");
await _viewModel.HandleFocusChangedAsync(true);
}
private async void OnSessionSurfaceLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
ClientTrace.Write($"SessionWindow surface lost keyboard focus. NewFocus={e.NewFocus?.GetType().Name}, OldFocus={e.OldFocus?.GetType().Name}, InputCaptured={_inputCaptured}");
if (!ShouldRetainSurfaceFocus(e.NewFocus))
{
ReleaseManualInputCapture("surface_lost_focus");
}
await _viewModel.HandleFocusChangedAsync(false);
if (ShouldRetainSurfaceFocus(e.NewFocus))
{
BeginSurfaceFocus("focus_redirect");
}
}
private async void OnSessionSurfacePreviewKeyDown(object sender, KeyEventArgs e)
{
if (_isClosing)
{
return;
}
if (e.Key == Key.Escape && _inputCaptured)
{
ReleaseManualInputCapture("escape");
_remoteFocusPrimed = false;
_ = _viewModel.HandleFocusChangedAsync(false);
e.Handled = true;
return;
}
if (SessionInputMapper.CreateKeyboardEvent(e, true) is not { } inputEvent)
{
return;
}
StampInputTrace(inputEvent);
if (!_keyboardObserved)
{
_keyboardObserved = true;
ClientTrace.Write($"SessionWindow observed keyboard input. Key={e.Key}, SystemKey={e.SystemKey}");
}
ClientTrace.Write($"input.trace wpf_captured correlation_id={inputEvent.CorrelationId} kind=keyboard action=key_down key={e.Key} captured_at={inputEvent.ClientCapturedAt} FocusWithin={SessionSurface.IsKeyboardFocusWithin} HasSurfaceFocus={_viewModel.HasSurfaceFocus} InputCaptured={_inputCaptured}");
PrimeRemoteFocusForInput("key_down");
SendInputWithoutBlocking(inputEvent);
e.Handled = true;
}
private async void OnSessionSurfacePreviewKeyUp(object sender, KeyEventArgs e)
{
if (_isClosing)
{
return;
}
if (SessionInputMapper.CreateKeyboardEvent(e, false) is not { } inputEvent)
{
return;
}
StampInputTrace(inputEvent);
ClientTrace.Write($"input.trace wpf_captured correlation_id={inputEvent.CorrelationId} kind=keyboard action=key_up key={e.Key} captured_at={inputEvent.ClientCapturedAt} FocusWithin={SessionSurface.IsKeyboardFocusWithin} HasSurfaceFocus={_viewModel.HasSurfaceFocus} InputCaptured={_inputCaptured}");
SendInputWithoutBlocking(inputEvent);
e.Handled = true;
}
private void OnSessionSurfacePreviewMouseMove(object sender, MouseEventArgs e)
{
if (_isClosing)
{
return;
}
if (sender is not IInputElement element)
{
return;
}
if (!IsPointerInsideDesktop())
{
// Leaving the rendered image is not a remote focus loss. Releasing
// focus here makes the next click act like a focus-only click.
ClearPendingMouseMoveInput();
return;
}
var mappedPointer = MapPointerToDesktop(element, clampToDesktop: true);
if (mappedPointer is null)
{
return;
}
Point position = mappedPointer.Value.Position;
DateTimeOffset now = DateTimeOffset.UtcNow;
if ((now - _lastMouseMoveAt) < TimeSpan.FromMilliseconds(16) &&
Math.Abs(position.X - _lastMousePosition.X) < 1 &&
Math.Abs(position.Y - _lastMousePosition.Y) < 1)
{
return;
}
if (!_mouseObserved)
{
_mouseObserved = true;
ClientTrace.Write("SessionWindow observed mouse input");
}
_lastMousePosition = position;
_lastMouseMoveAt = now;
var moveEvent = SessionInputMapper.CreateMouseMoveEvent(position, mappedPointer.Value.SurfaceSize);
StampInputTrace(moveEvent);
QueueMouseMoveInput(moveEvent);
if (_inputCaptured)
{
e.Handled = true;
}
}
private async void OnSessionSurfacePreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (_isClosing)
{
return;
}
if (sender is not UIElement element)
{
return;
}
AcquireManualInputCapture("mouse_down");
element.Focus();
PrimeRemoteFocusForInput("mouse_down");
string? button = e.ChangedButton switch
{
MouseButton.Left => "left",
MouseButton.Right => "right",
MouseButton.Middle => "middle",
_ => null
};
if (button is null)
{
return;
}
var mappedPointer = MapPointerToDesktop(element, clampToDesktop: false);
if (mappedPointer is null)
{
ReleaseManualInputCapture("mouse_down_outside_desktop");
return;
}
var buttonEvent = SessionInputMapper.CreateMouseButtonEvent(button, true, mappedPointer.Value.Position, mappedPointer.Value.SurfaceSize);
StampInputTrace(buttonEvent);
ClientTrace.Write($"input.trace wpf_captured correlation_id={buttonEvent.CorrelationId} kind=mouse action=button_down button={button} x={mappedPointer.Value.Position.X:F1} y={mappedPointer.Value.Position.Y:F1} captured_at={buttonEvent.ClientCapturedAt} FocusWithin={SessionSurface.IsKeyboardFocusWithin} HasSurfaceFocus={_viewModel.HasSurfaceFocus} InputCaptured={_inputCaptured}");
SendInputWithoutBlocking(buttonEvent);
e.Handled = true;
}
private async void OnSessionSurfacePreviewMouseUp(object sender, MouseButtonEventArgs e)
{
if (_isClosing)
{
return;
}
if (sender is not IInputElement element)
{
return;
}
string? button = e.ChangedButton switch
{
MouseButton.Left => "left",
MouseButton.Right => "right",
MouseButton.Middle => "middle",
_ => null
};
if (button is null)
{
return;
}
var mappedPointer = MapPointerToDesktop(element, clampToDesktop: false);
if (mappedPointer is null)
{
ReleaseManualInputCapture("mouse_up_outside_desktop");
return;
}
EnsureSurfaceFocusForInput("mouse_up");
var buttonEvent = SessionInputMapper.CreateMouseButtonEvent(button, false, mappedPointer.Value.Position, mappedPointer.Value.SurfaceSize);
StampInputTrace(buttonEvent);
ClientTrace.Write($"input.trace wpf_captured correlation_id={buttonEvent.CorrelationId} kind=mouse action=button_up button={button} x={mappedPointer.Value.Position.X:F1} y={mappedPointer.Value.Position.Y:F1} captured_at={buttonEvent.ClientCapturedAt} FocusWithin={SessionSurface.IsKeyboardFocusWithin} HasSurfaceFocus={_viewModel.HasSurfaceFocus} InputCaptured={_inputCaptured}");
SendInputWithoutBlocking(buttonEvent);
e.Handled = true;
}
private async void OnSessionSurfacePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (_isClosing)
{
return;
}
if (sender is not IInputElement element)
{
return;
}
var mappedPointer = MapPointerToDesktop(element, clampToDesktop: false);
if (mappedPointer is null)
{
ReleaseManualInputCapture("mouse_wheel_outside_desktop");
return;
}
EnsureSurfaceFocusForInput("mouse_wheel");
var wheelEvent = SessionInputMapper.CreateMouseWheelEvent(e.Delta, false, mappedPointer.Value.Position, mappedPointer.Value.SurfaceSize);
StampInputTrace(wheelEvent);
ClientTrace.Write($"input.trace wpf_captured correlation_id={wheelEvent.CorrelationId} kind=mouse action=wheel delta={e.Delta} x={mappedPointer.Value.Position.X:F1} y={mappedPointer.Value.Position.Y:F1} captured_at={wheelEvent.ClientCapturedAt} FocusWithin={SessionSurface.IsKeyboardFocusWithin} HasSurfaceFocus={_viewModel.HasSurfaceFocus} InputCaptured={_inputCaptured}");
SendInputWithoutBlocking(wheelEvent);
e.Handled = true;
}
private void OnSessionActionInvoked(object sender, RoutedEventArgs e)
{
ClientTrace.Write($"SessionWindow action invoked from {sender.GetType().Name}");
ReleaseManualInputCapture("session_action");
BeginSurfaceFocus("session_action");
}
private void OnClosing(object? sender, CancelEventArgs e)
{
ClientTrace.Write($"close.trace SessionWindow.Closing start title={Title}");
_isClosing = true;
_windowCloseCancellation.Cancel();
ClientTrace.Write($"close.trace window CTS cancelled title={Title}");
ClearQueuedInput();
lock (_mouseMoveSync)
{
_pendingMouseMoveInput = null;
_mouseMovePumpRunning = false;
}
ClientTrace.Write($"close.trace input queue cleared title={Title}");
lock (_framePreparationSync)
{
_pendingFrames.Clear();
_framePreparationRunning = false;
}
ClientTrace.Write($"close.trace frame queue cleared title={Title}");
ReleaseManualInputCapture("window_closing");
_viewModel.BeginUserClose();
ClientTrace.Write($"close.trace SessionWindow.Closing end title={Title} inputCaptured={_inputCaptured}");
}
private void OnClosed(object? sender, EventArgs e)
{
ClientTrace.Write($"close.trace SessionWindow.Closed start title={Title}");
_viewModel.PropertyChanged -= OnViewModelPropertyChanged;
Closing -= OnClosing;
Closed -= OnClosed;
Deactivated -= OnDeactivated;
ReleaseManualInputCapture("window_closed");
ClientTrace.Write($"close.trace SessionWindow.Closed handlers detached title={Title}");
string closedTitle = Title;
_ = Task.Run(async () =>
{
ClientTrace.Write($"close.trace ViewModel dispose background start title={closedTitle}");
await _viewModel.DisposeAsync().ConfigureAwait(false);
ClientTrace.Write($"close.trace ViewModel dispose background end title={closedTitle}");
});
ClientTrace.Write($"close.trace SessionWindow.Closed end title={Title}");
}
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (_isClosing)
{
return;
}
if (e.PropertyName == nameof(SessionWindowViewModel.LatestFrame))
{
ScheduleFrameSurfaceUpdate();
}
if (e.PropertyName == nameof(SessionWindowViewModel.PendingServerClipboardText))
{
ApplyPendingServerClipboardText();
}
}
private async void OnSendClipboardToServerClick(object sender, RoutedEventArgs e)
{
string? text = Clipboard.ContainsText(TextDataFormat.UnicodeText)
? Clipboard.GetText(TextDataFormat.UnicodeText)
: null;
await _viewModel.SendClipboardTextFromClientAsync(text);
}
private void OnReceiveClipboardFromServerClick(object sender, RoutedEventArgs e)
{
ApplyPendingServerClipboardText();
}
private async void OnUploadFileToServerClick(object sender, RoutedEventArgs e)
{
var dialog = new OpenFileDialog
{
CheckFileExists = true,
Multiselect = false
};
if (dialog.ShowDialog(this) == true)
{
await _viewModel.UploadFileToSessionAsync(dialog.FileName);
}
}
private void OnCancelFileUploadClick(object sender, RoutedEventArgs e)
{
_viewModel.CancelFileUpload();
}
private async void OnDownloadFileFromServerClick(object sender, RoutedEventArgs e)
{
var dialog = new SaveFileDialog
{
AddExtension = true,
OverwritePrompt = true,
ValidateNames = true
};
if (dialog.ShowDialog(this) == true)
{
await _viewModel.DownloadAvailableFileAsync(dialog.FileName);
}
}
private void OnCancelFileDownloadClick(object sender, RoutedEventArgs e)
{
_viewModel.CancelFileDownload();
}
private void ApplyPendingServerClipboardText()
{
string? text = _viewModel.ConsumePendingServerClipboardText();
if (!string.IsNullOrEmpty(text))
{
Clipboard.SetText(text, TextDataFormat.UnicodeText);
ClientTrace.Write($"SessionWindow applied server clipboard text length={text.Length}");
}
}
private void ScheduleFrameSurfaceUpdate()
{
if (_isClosing || _windowCloseCancellation.IsCancellationRequested)
{
return;
}
QueueFrameForPresentation(_viewModel.LatestFrame);
}
private void UpdateFrameSurface(PreparedDesktopFrame? frame)
{
if (frame is null)
{
_framePresenter.Reset(SessionFrameImage, SessionSurfaceOverlay);
return;
}
bool presented = _framePresenter.Present(SessionFrameImage, SessionSurfaceOverlay, frame);
if (!presented)
{
ClientTrace.Write($"SessionWindow skipped frame seq={frame.FrameSequence} region={frame.IsRegion} desktop={frame.Width}x{frame.Height} rect={frame.RegionX},{frame.RegionY},{frame.RegionWidth}x{frame.RegionHeight} stride={frame.Stride} bytes={frame.Pixels.Length}");
return;
}
if (frame.FrameSequence != _lastRenderedFrameSequence)
{
_lastRenderedFrameSequence = frame.FrameSequence;
ClientTrace.Write($"SessionWindow rendered frame seq={frame.FrameSequence} desktop={frame.Width}x{frame.Height} region={frame.IsRegion} rect={frame.RegionX},{frame.RegionY},{frame.RegionWidth}x{frame.RegionHeight} color_mode={frame.ColorMode}");
if (!string.IsNullOrWhiteSpace(frame.InputCorrelationId))
{
ClientTrace.Write($"input.trace wpf_frame_rendered correlation_id={frame.InputCorrelationId} frame_sequence={frame.FrameSequence} worker_frame_captured_at={frame.WorkerFrameCapturedAt}");
}
}
}
private PointerMapping? MapPointerToDesktop(IInputElement inputElement, bool clampToDesktop)
{
var source = SessionFrameImage.Source;
if (source is null)
{
return null;
}
double imageWidth = SessionFrameImage.ActualWidth;
double imageHeight = SessionFrameImage.ActualHeight;
if (imageWidth <= 0 || imageHeight <= 0 || source.Width <= 0 || source.Height <= 0)
{
return null;
}
Point pointer = Mouse.GetPosition(SessionFrameImage);
Rect desktopRect = GetVisibleDesktopRect(imageWidth, imageHeight, source.Width, source.Height);
bool isInsideDesktop = desktopRect.Contains(pointer);
if (!clampToDesktop && !isInsideDesktop)
{
ClientTrace.Write($"SessionWindow pointer outside desktop. PointerX={pointer.X:F1}, PointerY={pointer.Y:F1}, DesktopLeft={desktopRect.Left:F1}, DesktopTop={desktopRect.Top:F1}, DesktopWidth={desktopRect.Width:F1}, DesktopHeight={desktopRect.Height:F1}, InputCaptured={_inputCaptured}");
return null;
}
double x = clampToDesktop ? Math.Clamp(pointer.X, desktopRect.Left, desktopRect.Right) : pointer.X;
double y = clampToDesktop ? Math.Clamp(pointer.Y, desktopRect.Top, desktopRect.Bottom) : pointer.Y;
var translated = new Point(x - desktopRect.Left, y - desktopRect.Top);
var surfaceSize = new Size(desktopRect.Width, desktopRect.Height);
return new PointerMapping(translated, surfaceSize);
}
private bool IsPointerInsideDesktop()
{
var source = SessionFrameImage.Source;
if (source is null)
{
return false;
}
double imageWidth = SessionFrameImage.ActualWidth;
double imageHeight = SessionFrameImage.ActualHeight;
if (imageWidth <= 0 || imageHeight <= 0 || source.Width <= 0 || source.Height <= 0)
{
return false;
}
Point pointer = Mouse.GetPosition(SessionFrameImage);
Rect desktopRect = GetVisibleDesktopRect(imageWidth, imageHeight, source.Width, source.Height);
return desktopRect.Contains(pointer);
}
private void QueueMouseMoveInput(SessionInputEventDto inputEvent)
{
if (_isClosing || _windowCloseCancellation.IsCancellationRequested)
{
return;
}
bool startPump = false;
lock (_mouseMoveSync)
{
_mouseMoveCapturedSinceLog++;
_lastMouseMoveLogX = inputEvent.NormalizedX ?? 0;
_lastMouseMoveLogY = inputEvent.NormalizedY ?? 0;
if (_pendingMouseMoveInput is not null)
{
_mouseMoveCoalescedSinceLog++;
}
_pendingMouseMoveInput = inputEvent;
if (!_mouseMovePumpRunning)
{
_mouseMovePumpRunning = true;
startPump = true;
}
}
MaybeLogMouseMoveRate();
if (startPump)
{
_ = PumpMouseMoveInputAsync();
}
}
private static void StampInputTrace(SessionInputEventDto inputEvent)
{
inputEvent.CorrelationId = Guid.NewGuid().ToString("N");
inputEvent.ClientCapturedAt = DateTimeOffset.UtcNow.ToString("O");
}
private async Task PumpMouseMoveInputAsync()
{
CancellationToken cancellationToken = _windowCloseCancellation.Token;
while (!cancellationToken.IsCancellationRequested)
{
SessionInputEventDto? next;
lock (_mouseMoveSync)
{
next = _pendingMouseMoveInput;
_pendingMouseMoveInput = null;
if (next is null)
{
_mouseMovePumpRunning = false;
return;
}
}
TimeSpan wait = MouseMoveSendInterval - (DateTimeOffset.UtcNow - _lastMouseMoveSentAt);
if (wait > TimeSpan.Zero)
{
try
{
await Task.Delay(wait, cancellationToken);
}
catch (OperationCanceledException)
{
ClearPendingMouseMoveInput();
return;
}
}
if (_isClosing || cancellationToken.IsCancellationRequested)
{
ClearPendingMouseMoveInput();
return;
}
bool sent = SendInputWithoutBlocking(next);
lock (_mouseMoveSync)
{
if (sent)
{
_mouseMoveSentSinceLog++;
}
if (_isClosing || cancellationToken.IsCancellationRequested)
{
_pendingMouseMoveInput = null;
_mouseMovePumpRunning = false;
return;
}
}
_lastMouseMoveSentAt = DateTimeOffset.UtcNow;
MaybeLogMouseMoveRate();
}
ClearPendingMouseMoveInput();
}
private void ClearPendingMouseMoveInput()
{
lock (_mouseMoveSync)
{
_pendingMouseMoveInput = null;
_mouseMovePumpRunning = false;
}
}
private void QueueFrameForPresentation(SessionFrameDto? frame)
{
if (frame is null)
{
lock (_framePreparationSync)
{
_pendingFrames.Clear();
}
_ = Dispatcher.InvokeAsync(() =>
{
if (!_isClosing && !_windowCloseCancellation.IsCancellationRequested)
{
UpdateFrameSurface(null);
}
}, DispatcherPriority.Background);
return;
}
bool startWorker = false;
lock (_framePreparationSync)
{
if (IsFullFrame(frame))
{
if (_pendingFrames.Count > 0)
{
ClientTrace.Write($"render.region_queue WPF presenter cleared pending frames for full frame count={_pendingFrames.Count}");
}
_pendingFrames.Clear();
_pendingFrames.Enqueue(frame);
}
else
{
if (_pendingFrames.Count >= MaxOrderedFrameBacklog)
{
ClientTrace.Write($"render.region_queue WPF presenter overflow dropped={_pendingFrames.Count + 1}");
_pendingFrames.Clear();
}
else
{
_pendingFrames.Enqueue(frame);
}
}
if (!_framePreparationRunning)
{
_framePreparationRunning = true;
startWorker = true;
}
}
if (startWorker)
{
_ = Task.Run(ProcessLatestFramesAsync);
}
}
private async Task ProcessLatestFramesAsync()
{
CancellationToken cancellationToken = _windowCloseCancellation.Token;
while (!cancellationToken.IsCancellationRequested)
{
SessionFrameDto? frame;
lock (_framePreparationSync)
{
frame = _pendingFrames.Count > 0 ? _pendingFrames.Dequeue() : null;
if (frame is null)
{
_framePreparationRunning = false;
return;
}
}
PreparedDesktopFrame? preparedFrame;
try
{
preparedFrame = DesktopFramePresenter.Prepare(frame, cancellationToken);
}
catch (OperationCanceledException)
{
ClearPendingFrames();
return;
}
catch (Exception ex)
{
ClientTrace.Write($"SessionWindow frame prepare failed: {ex.Message}");
continue;
}
if (preparedFrame is null || _isClosing || cancellationToken.IsCancellationRequested)
{
ClearPendingFrames();
return;
}
try
{
await Dispatcher.InvokeAsync(() =>
{
if (_isClosing || cancellationToken.IsCancellationRequested)
{
return;
}
if (preparedFrame.FrameSequence <= _lastRenderedFrameSequence)
{
return;
}
UpdateFrameSurface(preparedFrame);
}, DispatcherPriority.Background, cancellationToken);
}
catch (OperationCanceledException)
{
ClearPendingFrames();
return;
}
}
ClearPendingFrames();
}
private void ClearPendingFrames()
{
lock (_framePreparationSync)
{
_pendingFrames.Clear();
_framePreparationRunning = false;
}
}
private static bool IsFullFrame(SessionFrameDto frame)
{
return string.Equals(frame.UpdateKind, "full", StringComparison.OrdinalIgnoreCase);
}
private bool SendInputWithoutBlocking(SessionInputEventDto inputEvent)
{
if (_isClosing || _windowCloseCancellation.IsCancellationRequested)
{
return false;
}
if (_inputQueue.Count > 256 && inputEvent.Kind == "mouse" && inputEvent.Action == "move")
{
return false;
}
_inputQueue.Enqueue(inputEvent);
if (Interlocked.Exchange(ref _inputPumpRunning, 1) == 0)
{
_ = Task.Run(PumpInputQueueAsync, _windowCloseCancellation.Token);
}
return true;
}
private async Task PumpInputQueueAsync()
{
CancellationToken cancellationToken = _windowCloseCancellation.Token;
try
{
while (!cancellationToken.IsCancellationRequested && !_isClosing)
{
if (!_inputQueue.TryDequeue(out var inputEvent))
{
Interlocked.Exchange(ref _inputPumpRunning, 0);
if (_inputQueue.IsEmpty || Interlocked.Exchange(ref _inputPumpRunning, 1) == 1)
{
return;
}
continue;
}
try
{
await _viewModel.TrySendInputAsync(inputEvent).ConfigureAwait(false);
}
catch (Exception ex)
{
ClientTrace.Write($"SessionWindow input send failed: {ex.Message}");
}
}
}
finally
{
Interlocked.Exchange(ref _inputPumpRunning, 0);
}
}
private void ClearQueuedInput()
{
while (_inputQueue.TryDequeue(out _))
{
}
Interlocked.Exchange(ref _inputPumpRunning, 0);
}
private void PrimeRemoteFocusForInput(string reason)
{
if (_remoteFocusPrimed)
{
return;
}
_viewModel.MarkSurfaceFocusForInput(reason);
var focusEvent = SessionInputMapper.CreateFocusEvent(true);
StampInputTrace(focusEvent);
_remoteFocusPrimed = true;
SendInputWithoutBlocking(focusEvent);
}
private void MaybeLogMouseMoveRate()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
int captured;
int sent;
int coalesced;
lock (_mouseMoveSync)
{
if ((now - _lastMouseMoveRateLogAt) < MouseMoveRateLogInterval)
{
return;
}
captured = _mouseMoveCapturedSinceLog;
sent = _mouseMoveSentSinceLog;
coalesced = _mouseMoveCoalescedSinceLog;
_mouseMoveCapturedSinceLog = 0;
_mouseMoveSentSinceLog = 0;
_mouseMoveCoalescedSinceLog = 0;
_lastMouseMoveRateLogAt = now;
}
double seconds = Math.Max(0.001d, MouseMoveRateLogInterval.TotalSeconds);
ClientTrace.Write($"input.trace mouse_move_client_rate capturedPerSecond={captured / seconds:F1} sentPerSecond={sent / seconds:F1} coalesced={coalesced} latestX={_lastMouseMoveLogX:F4} latestY={_lastMouseMoveLogY:F4} inputCaptured={_inputCaptured} focusWithin={SessionSurface.IsKeyboardFocusWithin} hasSurfaceFocus={_viewModel.HasSurfaceFocus}");
}
private void AcquireManualInputCapture(string reason)
{
if (!_inputCaptured)
{
_inputCaptured = true;
ClientTrace.Write($"SessionWindow manual input captured on {reason}. MouseCaptured=false, IsKeyboardFocusWithin={SessionSurface.IsKeyboardFocusWithin}");
}
}
private void ReleaseManualInputCapture(string reason)
{
if (!_inputCaptured)
{
return;
}
if (ReferenceEquals(Mouse.Captured, SessionSurface))
{
Mouse.Capture(null);
}
_inputCaptured = false;
ClientTrace.Write($"SessionWindow manual input released on {reason}. IsKeyboardFocusWithin={SessionSurface.IsKeyboardFocusWithin}");
}
private async Task EnsureSurfaceFocusAsync(string reason)
{
if (!SessionSurface.IsKeyboardFocusWithin)
{
bool focused = SessionSurface.Focus();
Keyboard.Focus(SessionSurface);
ClientTrace.Write($"SessionWindow ensure surface focus on {reason}. FocusResult={focused}, IsKeyboardFocusWithin={SessionSurface.IsKeyboardFocusWithin}");
}
if (!_viewModel.HasSurfaceFocus)
{
await _viewModel.HandleFocusChangedAsync(true);
}
}
private void EnsureSurfaceFocusForInput(string reason)
{
if (!SessionSurface.IsKeyboardFocusWithin)
{
bool focused = SessionSurface.Focus();
Keyboard.Focus(SessionSurface);
ClientTrace.Write($"SessionWindow ensure surface focus for input on {reason}. FocusResult={focused}, IsKeyboardFocusWithin={SessionSurface.IsKeyboardFocusWithin}");
}
_viewModel.MarkSurfaceFocusForInput(reason);
}
private bool ShouldRetainSurfaceFocus(object? newFocus)
{
if (newFocus is null)
{
return false;
}
if (ReferenceEquals(newFocus, SessionSurface) || SessionSurface.IsKeyboardFocusWithin)
{
return false;
}
return _inputCaptured && (newFocus is Button or Expander or ScrollViewer or ListBox or ListBoxItem);
}
private static Rect GetVisibleDesktopRect(double controlWidth, double controlHeight, double sourceWidth, double sourceHeight)
{
double sourceAspect = sourceWidth / sourceHeight;
double controlAspect = controlWidth / controlHeight;
if (controlAspect > sourceAspect)
{
double renderedWidth = controlHeight * sourceAspect;
double offsetX = (controlWidth - renderedWidth) / 2d;
return new Rect(offsetX, 0d, renderedWidth, controlHeight);
}
double renderedHeight = controlWidth / sourceAspect;
double offsetY = (controlHeight - renderedHeight) / 2d;
return new Rect(0d, offsetY, controlWidth, renderedHeight);
}
private readonly record struct PointerMapping(Point Position, Size SurfaceSize);
}
@@ -0,0 +1,54 @@
<UserControl x:Class="RemoteAccessPlatform.Windows.App.Views.ActiveSessionsView"
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"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d">
<Border Padding="16"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<DockPanel>
<StackPanel DockPanel.Dock="Top"
Orientation="Horizontal"
Margin="0,0,0,12">
<TextBlock VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.SessionsHeading}" />
<Button Margin="16,0,0,0"
AutomationProperties.AutomationId="RefreshSessionsButton"
AutomationProperties.Name="{x:Static appres:Strings.RefreshSessions}"
Command="{Binding RefreshSessionsCommand}"
Content="{x:Static appres:Strings.RefreshSessions}" />
</StackPanel>
<ListView ItemsSource="{Binding Sessions}"
AutomationProperties.AutomationId="SessionListView"
AutomationProperties.Name="{x:Static appres:Strings.SessionsHeading}"
SelectedItem="{Binding SelectedSession}">
<ListView.View>
<GridView>
<GridViewColumn Header="{x:Static appres:Strings.SessionColumnSession}"
Width="220"
DisplayMemberBinding="{Binding ID}" />
<GridViewColumn Header="{x:Static appres:Strings.SessionColumnState}"
Width="110"
DisplayMemberBinding="{Binding State}" />
<GridViewColumn Header="{x:Static appres:Strings.SessionColumnProtocol}"
Width="90"
DisplayMemberBinding="{Binding Protocol}" />
<GridViewColumn Header="{x:Static appres:Strings.SessionColumnWorker}"
Width="150"
DisplayMemberBinding="{Binding WorkerID}" />
<GridViewColumn Header="{x:Static appres:Strings.SessionColumnTakeover}"
Width="80"
DisplayMemberBinding="{Binding TakeoverVersion}" />
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace RemoteAccessPlatform.Windows.App.Views;
public partial class ActiveSessionsView : UserControl
{
public ActiveSessionsView()
{
InitializeComponent();
}
}
@@ -0,0 +1,54 @@
<UserControl x:Class="RemoteAccessPlatform.Windows.App.Views.LoginView"
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"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d">
<Border Padding="24"
Background="#FFF7F7F9"
BorderBrush="#FFD6D6DB"
BorderThickness="1"
CornerRadius="12"
MaxWidth="420">
<StackPanel>
<TextBlock FontSize="26"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.LoginHeading}" />
<TextBlock Margin="0,8,0,20"
Foreground="#FF666A73"
Text="{x:Static appres:Strings.LoginSubtitle}" />
<TextBlock Margin="0,0,0,6"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.LoginEmail}" />
<TextBox Margin="0,0,0,14"
AutomationProperties.AutomationId="LoginEmailTextBox"
AutomationProperties.Name="{x:Static appres:Strings.LoginEmail}"
Padding="10,8"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Margin="0,0,0,6"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.LoginPassword}" />
<PasswordBox x:Name="PasswordBox"
Margin="0,0,0,14"
AutomationProperties.AutomationId="LoginPasswordBox"
AutomationProperties.Name="{x:Static appres:Strings.LoginPassword}"
Padding="10,8"
PasswordChanged="PasswordBox_OnPasswordChanged" />
<CheckBox Margin="0,0,0,20"
AutomationProperties.AutomationId="TrustDeviceCheckBox"
AutomationProperties.Name="{x:Static appres:Strings.LoginTrustDevice}"
Content="{x:Static appres:Strings.LoginTrustDevice}"
IsChecked="{Binding TrustDevice}" />
<Button Height="40"
AutomationProperties.AutomationId="SignInButton"
AutomationProperties.Name="{x:Static appres:Strings.LoginSignIn}"
Command="{Binding LoginCommand}"
Content="{x:Static appres:Strings.LoginSignIn}" />
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,21 @@
using System.Windows;
using System.Windows.Controls;
using RemoteAccessPlatform.Windows.Application.ViewModels;
namespace RemoteAccessPlatform.Windows.App.Views;
public partial class LoginView : UserControl
{
public LoginView()
{
InitializeComponent();
}
private void PasswordBox_OnPasswordChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel viewModel)
{
viewModel.Password = PasswordBox.Password;
}
}
}
@@ -0,0 +1,45 @@
<UserControl x:Class="RemoteAccessPlatform.Windows.App.Views.OrganizationSwitchView"
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"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d">
<Border Padding="16"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock FontSize="18"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.OrganizationHeading}" />
<TextBlock Margin="0,4,0,12"
Foreground="#FF666A73"
Text="{x:Static appres:Strings.OrganizationSubtitle}" />
<ComboBox Width="320"
AutomationProperties.AutomationId="OrganizationComboBox"
AutomationProperties.Name="{x:Static appres:Strings.OrganizationHeading}"
DisplayMemberPath="Name"
ItemsSource="{Binding Organizations}"
SelectedItem="{Binding SelectedOrganization}" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Bottom">
<Button MinWidth="140"
AutomationProperties.AutomationId="SetActiveOrganizationButton"
AutomationProperties.Name="{x:Static appres:Strings.OrganizationSetActive}"
Command="{Binding SwitchOrganizationCommand}"
Content="{x:Static appres:Strings.OrganizationSetActive}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace RemoteAccessPlatform.Windows.App.Views;
public partial class OrganizationSwitchView : UserControl
{
public OrganizationSwitchView()
{
InitializeComponent();
}
}
@@ -0,0 +1,51 @@
<UserControl x:Class="RemoteAccessPlatform.Windows.App.Views.ResourceListView"
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"
xmlns:appres="clr-namespace:RemoteAccessPlatform.Windows.Application.Localization;assembly=RemoteAccessPlatform.Windows.Application"
mc:Ignorable="d">
<Border Padding="16"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<DockPanel>
<StackPanel DockPanel.Dock="Top"
Orientation="Horizontal"
Margin="0,0,0,12">
<TextBlock VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.ResourceHeading}" />
<Button Margin="16,0,0,0"
AutomationProperties.AutomationId="RefreshResourcesButton"
AutomationProperties.Name="{x:Static appres:Strings.RefreshResources}"
Command="{Binding RefreshResourcesCommand}"
Content="{x:Static appres:Strings.RefreshResources}" />
</StackPanel>
<ListView ItemsSource="{Binding Resources}"
AutomationProperties.AutomationId="ResourceListView"
AutomationProperties.Name="{x:Static appres:Strings.ResourceHeading}"
SelectedItem="{Binding SelectedResource}">
<ListView.View>
<GridView>
<GridViewColumn Header="{x:Static appres:Strings.ResourceColumnName}"
Width="220"
DisplayMemberBinding="{Binding Name}" />
<GridViewColumn Header="{x:Static appres:Strings.ResourceColumnType}"
Width="90"
DisplayMemberBinding="{Binding Protocol}" />
<GridViewColumn Header="{x:Static appres:Strings.ResourceColumnHost}"
Width="220"
DisplayMemberBinding="{Binding Address}" />
<GridViewColumn Header="{x:Static appres:Strings.ResourceColumnCert}"
Width="100"
DisplayMemberBinding="{Binding CertificateVerificationMode}" />
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace RemoteAccessPlatform.Windows.App.Views;
public partial class ResourceListView : UserControl
{
public ResourceListView()
{
InitializeComponent();
}
}
@@ -0,0 +1,12 @@
{
"backend": {
"prefer_direct_data_plane": true,
"api_base_url": "http://192.168.200.61:8080/api/v1",
"gateway_websocket_url": "ws://192.168.200.61:8080/api/v1/gateway/ws",
"environment": "development",
"direct_data_plane_connect_timeout_ms": 2500,
"direct_data_plane_color_mode": "full_color",
"direct_data_plane_platform_ca_bundle": "",
"allow_insecure_direct_data_plane_tls_for_smoke": true
}
}
@@ -0,0 +1,26 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace RemoteAccessPlatform.Windows.Application.Common;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
return true;
}
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
@@ -0,0 +1,96 @@
using System.Diagnostics;
using System.Windows.Input;
namespace RemoteAccessPlatform.Windows.Application.Common;
public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => execute();
public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class RelayCommand<T>(Action<T?> execute, Func<T?, bool>? canExecute = null) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute?.Invoke((T?)parameter) ?? true;
public void Execute(object? parameter) => execute((T?)parameter);
public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class AsyncRelayCommand(Func<Task> executeAsync, Func<bool>? canExecute = null) : ICommand
{
private bool _isExecuting;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => !_isExecuting && (canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
{
return;
}
_isExecuting = true;
NotifyCanExecuteChanged();
try
{
await executeAsync();
}
catch (Exception ex)
{
Trace.WriteLine($"[{DateTimeOffset.Now:O}] AsyncRelayCommand ignored unhandled command exception: {ex}");
}
finally
{
_isExecuting = false;
NotifyCanExecuteChanged();
}
}
public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class AsyncRelayCommand<T>(Func<T?, Task> executeAsync, Func<T?, bool>? canExecute = null) : ICommand
{
private bool _isExecuting;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => !_isExecuting && (canExecute?.Invoke((T?)parameter) ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
{
return;
}
_isExecuting = true;
NotifyCanExecuteChanged();
try
{
await executeAsync((T?)parameter);
}
catch (Exception ex)
{
Trace.WriteLine($"[{DateTimeOffset.Now:O}] AsyncRelayCommand<T> ignored unhandled command exception: {ex}");
}
finally
{
_isExecuting = false;
NotifyCanExecuteChanged();
}
}
public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -0,0 +1,109 @@
using System.Globalization;
using System.Resources;
namespace RemoteAccessPlatform.Windows.Application.Localization;
public static class Strings
{
private static readonly ResourceManager ResourceManager = new("RemoteAccessPlatform.Windows.Application.Resources.Strings", typeof(Strings).Assembly);
public static CultureInfo? Culture
{
get => CultureInfo.CurrentUICulture;
set
{
if (value is not null)
{
CultureInfo.CurrentUICulture = value;
}
}
}
public static string MainWindowTitle => Get("ui.main.title");
public static string MainHeaderTitle => Get("ui.main.title");
public static string MainLogout => Get("ui.main.logout");
public static string MainStartSession => Get("ui.main.start_session");
public static string MainAttach => Get("ui.main.attach");
public static string MainTakeOver => Get("ui.main.take_over");
public static string MainTerminate => Get("ui.main.terminate");
public static string LoginHeading => Get("ui.login.heading");
public static string LoginSubtitle => Get("ui.login.subtitle");
public static string LoginEmail => Get("ui.login.email");
public static string LoginPassword => Get("ui.login.password");
public static string LoginTrustDevice => Get("ui.login.trust_device");
public static string LoginSignIn => Get("ui.login.sign_in");
public static string OrganizationHeading => Get("ui.organization.heading");
public static string OrganizationSubtitle => Get("ui.organization.subtitle");
public static string OrganizationSetActive => Get("ui.organization.set_active");
public static string ResourceHeading => Get("ui.resources.heading");
public static string RefreshResources => Get("ui.resources.refresh");
public static string ResourceColumnName => Get("ui.resources.column.name");
public static string ResourceColumnType => Get("ui.resources.column.type");
public static string ResourceColumnHost => Get("ui.resources.column.host");
public static string ResourceColumnCert => Get("ui.resources.column.cert");
public static string SessionsHeading => Get("ui.sessions.heading");
public static string RefreshSessions => Get("ui.sessions.refresh");
public static string SessionColumnSession => Get("ui.sessions.column.session");
public static string SessionColumnState => Get("ui.sessions.column.state");
public static string SessionColumnProtocol => Get("ui.sessions.column.protocol");
public static string SessionColumnWorker => Get("ui.sessions.column.worker");
public static string SessionColumnTakeover => Get("ui.sessions.column.takeover");
public static string SessionWindowReconnect => Get("ui.session_window.reconnect");
public static string SessionWindowDetach => Get("ui.session_window.detach");
public static string SessionWindowTakeOver => Get("ui.session_window.take_over");
public static string SessionWindowTerminate => Get("ui.session_window.terminate");
public static string SessionWindowIntro => Get("ui.session_window.intro");
public static string SessionWindowEventLog => Get("ui.session_window.event_log");
public static string SessionWindowRenderHeading => Get("ui.session_window.render.heading");
public static string SessionWindowClipboardSend => Get("ui.session_window.clipboard.send");
public static string SessionWindowClipboardReceive => Get("ui.session_window.clipboard.receive");
public static string SessionWindowFileUpload => Get("ui.session_window.file_upload.send");
public static string SessionWindowFileUploadCancel => Get("ui.session_window.file_upload.cancel");
public static string SessionWindowFileDownload => Get("ui.session_window.file_download.receive");
public static string SessionWindowFileDownloadCancel => Get("ui.session_window.file_download.cancel");
public static string SessionWindowSurfaceTitle => Get("ui.session_window.surface_title");
public static string SessionWindowSurfaceSubtitle => Get("ui.session_window.surface_subtitle");
public static string Get(string key)
{
return ResourceManager.GetString(key, Culture) ?? key;
}
public static string Format(string key, params object[] args)
{
return string.Format(CultureInfo.CurrentUICulture, Get(key), args);
}
public static string ResolveBackendMessage(string? messageKey, string? fallbackMessage, string? code = null)
{
if (!string.IsNullOrWhiteSpace(messageKey))
{
string? localized = ResourceManager.GetString(messageKey!, Culture);
if (!string.IsNullOrWhiteSpace(localized))
{
return localized;
}
}
if (!string.IsNullOrWhiteSpace(fallbackMessage))
{
return fallbackMessage!;
}
if (!string.IsNullOrWhiteSpace(code))
{
string? localized = ResourceManager.GetString("errors." + code, Culture);
if (!string.IsNullOrWhiteSpace(localized))
{
return localized;
}
}
return Get("errors.common.unexpected");
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Contracts\RemoteAccessPlatform.Windows.Contracts.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<data name="ui.main.title"><value>Remote Access Platform</value></data>
<data name="ui.main.logout"><value>Logout</value></data>
<data name="ui.main.start_session"><value>Start Session</value></data>
<data name="ui.main.attach"><value>Attach</value></data>
<data name="ui.main.take_over"><value>Take Over</value></data>
<data name="ui.main.terminate"><value>Terminate</value></data>
<data name="ui.login.heading"><value>Secure Access</value></data>
<data name="ui.login.subtitle"><value>Sign in to load organizations, resources, and active sessions.</value></data>
<data name="ui.login.email"><value>Login</value></data>
<data name="ui.login.password"><value>Password</value></data>
<data name="ui.login.trust_device"><value>Trust this device for refresh sessions</value></data>
<data name="ui.login.sign_in"><value>Sign In</value></data>
<data name="ui.organization.heading"><value>Organization Context</value></data>
<data name="ui.organization.subtitle"><value>The selected organization scopes resources and admin actions.</value></data>
<data name="ui.organization.set_active"><value>Set Active Org</value></data>
<data name="ui.resources.heading"><value>Accessible Resources</value></data>
<data name="ui.resources.refresh"><value>Refresh</value></data>
<data name="ui.resources.column.name"><value>Name</value></data>
<data name="ui.resources.column.type"><value>Type</value></data>
<data name="ui.resources.column.host"><value>Host</value></data>
<data name="ui.resources.column.cert"><value>Cert</value></data>
<data name="ui.sessions.heading"><value>Active Sessions</value></data>
<data name="ui.sessions.refresh"><value>Refresh</value></data>
<data name="ui.sessions.column.session"><value>Session</value></data>
<data name="ui.sessions.column.state"><value>State</value></data>
<data name="ui.sessions.column.protocol"><value>Protocol</value></data>
<data name="ui.sessions.column.worker"><value>Worker</value></data>
<data name="ui.sessions.column.takeover"><value>Takeover</value></data>
<data name="ui.session_window.reconnect"><value>Reconnect</value></data>
<data name="ui.session_window.detach"><value>Detach</value></data>
<data name="ui.session_window.take_over"><value>Take Over</value></data>
<data name="ui.session_window.terminate"><value>Terminate</value></data>
<data name="ui.session_window.intro"><value>This MVP window proves attach, detach, reconnect, takeover, and terminate flows over the existing session gateway. Rendering is intentionally deferred to future viewer work.</value></data>
<data name="ui.session_window.event_log"><value>Session Event Log</value></data>
<data name="ui.session_window.render.heading"><value>Rendering</value></data>
<data name="ui.session_window.render_profile_prefix"><value>Render profile: {0}</value></data>
<data name="ui.session_window.render_state_prefix"><value>Render state: {0}</value></data>
<data name="ui.session_window.render_size_prefix"><value>Surface size: {0}</value></data>
<data name="ui.session_window.render_size.unknown"><value>unknown</value></data>
<data name="ui.session_window.render_cursor_prefix"><value>Cursor: {0}</value></data>
<data name="ui.session_window.render_cursor_value"><value>{0} @ {1}, {2}</value></data>
<data name="ui.session_window.render_cursor.visible"><value>visible</value></data>
<data name="ui.session_window.render_cursor.hidden"><value>hidden</value></data>
<data name="ui.session_window.render_telemetry_prefix"><value>Dirty updates: {0}; last render: {1}</value></data>
<data name="ui.session_window.render_last_update.unknown"><value>unknown</value></data>
<data name="ui.session_window.clipboard.send"><value>Send Clipboard Text</value></data>
<data name="ui.session_window.clipboard.receive"><value>Receive Clipboard Text</value></data>
<data name="ui.session_window.file_upload.send"><value>Upload File</value></data>
<data name="ui.session_window.file_upload.cancel"><value>Cancel Upload</value></data>
<data name="ui.session_window.file_upload.ready"><value>File upload is available only for active sessions allowed by resource policy.</value></data>
<data name="ui.session_window.file_download.receive"><value>Download File</value></data>
<data name="ui.session_window.file_download.cancel"><value>Cancel Download</value></data>
<data name="ui.session_window.file_download.ready"><value>Copy a file into RAP_Transfers\ToClient inside the remote session to make it available for download.</value></data>
<data name="ui.session_window.file_download.none"><value>No remote files are available for download.</value></data>
<data name="ui.session_window.file_download.available"><value>Available: {0} ({1} bytes)</value></data>
<data name="ui.session_window.clipboard.connecting"><value>Clipboard is waiting for an active live session.</value></data>
<data name="ui.session_window.clipboard.ready"><value>Text clipboard is available when allowed by resource policy.</value></data>
<data name="ui.session_window.clipboard.reconnecting"><value>Clipboard is paused while the session is reconnecting or updating.</value></data>
<data name="ui.session_window.clipboard.detached"><value>Clipboard is disabled while the session is detached.</value></data>
<data name="ui.session_window.clipboard.unavailable"><value>Clipboard is unavailable because the session is no longer active.</value></data>
<data name="ui.session_window.clipboard.taken_over"><value>Clipboard is disabled because another controller took over the session.</value></data>
<data name="ui.session_window.render.profile.low_bandwidth"><value>Low bandwidth</value></data>
<data name="ui.session_window.render.profile.balanced"><value>Balanced</value></data>
<data name="ui.session_window.render.profile.high_quality"><value>High quality</value></data>
<data name="ui.session_window.render.profile.text_priority"><value>Text priority</value></data>
<data name="ui.session_window.render.state.connecting"><value>Connecting</value></data>
<data name="ui.session_window.render.state.ready"><value>Ready</value></data>
<data name="ui.session_window.render.state.dirty"><value>Dirty updates</value></data>
<data name="ui.session_window.render.state.resized"><value>Resized</value></data>
<data name="ui.session_window.render.state.cursor"><value>Cursor updated</value></data>
<data name="ui.session_window.render.state.detached"><value>Detached</value></data>
<data name="ui.session_window.render.state.reconnecting"><value>Reconnecting</value></data>
<data name="ui.session_window.render.state.taken_over"><value>Taken over</value></data>
<data name="ui.session_window.render.state.failed"><value>Failed</value></data>
<data name="ui.session_window.render.state.terminated"><value>Terminated</value></data>
<data name="ui.session_window.render.state.unknown"><value>Unknown</value></data>
<data name="ui.session_window.state_prefix"><value>Session state: {0}</value></data>
<data name="ui.session_window.gateway_prefix"><value>Gateway: {0}</value></data>
<data name="ui.session_window.shell_state_prefix"><value>Shell state: {0}</value></data>
<data name="ui.session_window.summary.connecting"><value>Preparing the remote session shell and waiting for the live gateway connection.</value></data>
<data name="ui.session_window.summary.active"><value>The remote RDP session is active and attached to this window.</value></data>
<data name="ui.session_window.summary.detached"><value>The remote session is still running on the worker. You can reconnect without recreating it.</value></data>
<data name="ui.session_window.summary.failed"><value>The remote session failed. Review the event log and start a new session if needed.</value></data>
<data name="ui.session_window.summary.terminated"><value>The remote session has been terminated and will not accept reconnects.</value></data>
<data name="ui.session_window.summary.taken_over"><value>This window is no longer the active controller for the remote session.</value></data>
<data name="ui.session_window.actions.connecting"><value>Available actions will become active once the session finishes connecting.</value></data>
<data name="ui.session_window.actions.active"><value>You can detach, terminate, or hand over control from this window.</value></data>
<data name="ui.session_window.actions.detached"><value>Reconnect is available while the session keeps running on the worker.</value></data>
<data name="ui.session_window.actions.failed"><value>This session is no longer usable. Close the window or return to the session list.</value></data>
<data name="ui.session_window.actions.terminated"><value>This session is complete. Only close the window or start a new session from the main shell.</value></data>
<data name="ui.session_window.actions.taken_over"><value>Control moved to another controller. This window remains read-only for status and history.</value></data>
<data name="ui.session_window.actions.updating"><value>Updating remote session state. Commands are temporarily disabled.</value></data>
<data name="ui.session_window.surface_title"><value>Session Input Surface</value></data>
<data name="ui.session_window.surface_subtitle"><value>Focus this surface to send keyboard and mouse input to the active remote RDP session.</value></data>
<data name="ui.session_window.input.connecting"><value>Input is waiting for an active live session.</value></data>
<data name="ui.session_window.input.ready"><value>Click inside the surface to focus it before typing or using the mouse.</value></data>
<data name="ui.session_window.input.focused"><value>Input focus is active in this window.</value></data>
<data name="ui.session_window.input.reconnecting"><value>Input is paused while the session is reconnecting or updating.</value></data>
<data name="ui.session_window.input.detached"><value>Input is disabled while the session is detached.</value></data>
<data name="ui.session_window.input.unavailable"><value>Input is unavailable because the session is no longer active.</value></data>
<data name="ui.session_window.input.taken_over"><value>Input is disabled because another controller took over the session.</value></data>
<data name="status.ready"><value>Ready</value></data>
<data name="status.auth.logging_in"><value>Logging in...</value></data>
<data name="status.auth.logged_in"><value>Login successful</value></data>
<data name="status.auth.logging_out"><value>Logging out...</value></data>
<data name="status.auth.logged_out"><value>Logged out</value></data>
<data name="status.auth.session_expired"><value>Session expired. Please sign in again.</value></data>
<data name="status.organization.switching"><value>Switching organization...</value></data>
<data name="status.organization.active"><value>Active organization: {0}</value></data>
<data name="status.resources.refreshing"><value>Refreshing resources...</value></data>
<data name="status.resources.loaded"><value>Loaded {0} resources</value></data>
<data name="status.sessions.refreshing"><value>Refreshing sessions...</value></data>
<data name="status.sessions.loaded"><value>Loaded {0} sessions</value></data>
<data name="status.workspace.ready"><value>Workspace ready</value></data>
<data name="status.session.starting"><value>Starting session for {0}...</value></data>
<data name="status.session.attaching"><value>Attaching to {0}...</value></data>
<data name="status.session.taking_over"><value>Taking over {0}...</value></data>
<data name="status.session.terminating"><value>Terminating {0}...</value></data>
<data name="status.session.terminated"><value>Terminated session {0}</value></data>
<data name="status.session.window.start"><value>Start: {0}</value></data>
<data name="status.session.window.attach"><value>Attach: {0}</value></data>
<data name="status.session.window.takeover"><value>Takeover: {0}</value></data>
<data name="status.session.connection.pending"><value>Pending</value></data>
<data name="status.session.connection.connecting"><value>Connecting</value></data>
<data name="status.session.connection.reconnecting"><value>Reconnecting</value></data>
<data name="status.session.connection.connected"><value>Connected</value></data>
<data name="status.session.connection.taken_over"><value>Taken over by another controller</value></data>
<data name="status.session.connection.transport_closed"><value>Transport closed</value></data>
<data name="status.session.connection.disconnected"><value>Disconnected</value></data>
<data name="status.session.connection.detached"><value>Detached</value></data>
<data name="status.session.connection.failed"><value>Session failed</value></data>
<data name="status.session.connection.terminated"><value>Session terminated</value></data>
<data name="status.session.shell.connecting"><value>Connecting</value></data>
<data name="status.session.shell.active"><value>Active</value></data>
<data name="status.session.shell.detached"><value>Detached</value></data>
<data name="status.session.shell.reconnecting"><value>Reconnecting</value></data>
<data name="status.session.shell.failed"><value>Failed</value></data>
<data name="status.session.shell.terminated"><value>Terminated</value></data>
<data name="status.session.shell.taken_over"><value>Taken over</value></data>
<data name="status.session.shell.updating"><value>Updating</value></data>
<data name="events.session.window_created"><value>Session window created for {0}</value></data>
<data name="events.session.gateway_connected"><value>Connected to session gateway</value></data>
<data name="events.session.detached"><value>Detached from session</value></data>
<data name="events.session.takeover_requested"><value>Takeover requested for this device</value></data>
<data name="events.session.terminated"><value>Session terminated</value></data>
<data name="events.session.taken_over"><value>This session was taken over from another device.</value></data>
<data name="events.session.frame"><value>The session desktop frame updated.</value></data>
<data name="events.session.frame_received"><value>Desktop frame received.</value></data>
<data name="events.session.failed_after_transport_closed"><value>Session failed after transport closed</value></data>
<data name="events.session.terminated_after_transport_closed"><value>Session terminated after transport closed</value></data>
<data name="events.session.transport"><value>Envelope: {0}</value></data>
<data name="events.input.surface_focus_acquired"><value>Session input surface focused</value></data>
<data name="events.input.surface_focus_lost"><value>Session input surface focus lost</value></data>
<data name="events.input.keyboard_forwarded"><value>Keyboard input forwarding active</value></data>
<data name="events.input.mouse_forwarded"><value>Mouse input forwarding active</value></data>
<data name="events.clipboard.client_to_server_sent"><value>Clipboard text sent to the remote session.</value></data>
<data name="events.clipboard.server_to_client_received"><value>Clipboard text received from the remote session.</value></data>
<data name="events.clipboard.text_received"><value>Clipboard text received from the remote session.</value></data>
<data name="events.clipboard.blocked"><value>Clipboard transfer is blocked by session state or resource policy.</value></data>
<data name="events.file_upload.started"><value>Started uploading {0}.</value></data>
<data name="events.file_upload.transferring"><value>Uploading file...</value></data>
<data name="events.file_upload.completed"><value>Finished uploading {0}.</value></data>
<data name="events.file_upload.gateway_started"><value>Gateway accepted file upload.</value></data>
<data name="events.file_upload.gateway_completed"><value>Gateway routed all upload chunks to the worker.</value></data>
<data name="events.file_upload.cancelled"><value>File upload cancelled.</value></data>
<data name="events.file_upload.failed"><value>File upload failed.</value></data>
<data name="events.file_upload.blocked"><value>File upload is blocked by session state or resource policy.</value></data>
<data name="events.file_download.available"><value>Remote file is available for download: {0}.</value></data>
<data name="events.file_download.started"><value>Started downloading {0}.</value></data>
<data name="events.file_download.transferring"><value>Downloading file...</value></data>
<data name="events.file_download.completed"><value>File download completed.</value></data>
<data name="events.file_download.gateway_started"><value>Gateway accepted file download.</value></data>
<data name="events.file_download.cancelled"><value>File download cancelled.</value></data>
<data name="events.file_download.failed"><value>File download failed.</value></data>
<data name="events.file_download.blocked"><value>File download is blocked by session state or resource policy.</value></data>
<data name="errors.common.unexpected"><value>Something went wrong. Please try again.</value></data>
<data name="errors.common.access_denied"><value>Access denied.</value></data>
<data name="errors.common.internal_server_error"><value>An internal server error occurred.</value></data>
<data name="errors.bad_request.user_id_is_required"><value>user_id is required</value></data>
<data name="errors.auth.invalid_credentials"><value>Invalid credentials.</value></data>
<data name="errors.auth.session_expired"><value>Session expired. Please sign in again.</value></data>
<data name="events.session.transport_closed"><value>The session transport closed.</value></data>
</root>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
</root>
@@ -0,0 +1,125 @@
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Application.Services;
public sealed class AuthenticationService
{
private readonly IBackendAuthClient _authClient;
private readonly ITokenStore _tokenStore;
private readonly IDeviceIdentityProvider _deviceIdentityProvider;
private StoredAuthState? _current;
public AuthenticationService(IBackendAuthClient authClient, ITokenStore tokenStore, IDeviceIdentityProvider deviceIdentityProvider)
{
_authClient = authClient;
_tokenStore = tokenStore;
_deviceIdentityProvider = deviceIdentityProvider;
}
public event Action? StateChanged;
public event Action? SessionExpired;
public StoredAuthState? Current => _current;
public bool IsAuthenticated => _current is not null;
public string? CurrentUserId => _current?.User.ID;
public string? CurrentDeviceId => _current?.Device.ID;
public string? CurrentAccessToken => _current?.Tokens.AccessToken;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
_current = await _tokenStore.LoadAsync(cancellationToken);
StateChanged?.Invoke();
}
public async Task LoginAsync(string email, string password, bool trustDevice, CancellationToken cancellationToken)
{
string fingerprint = await _deviceIdentityProvider.GetOrCreateFingerprintAsync(cancellationToken);
string label = await _deviceIdentityProvider.GetDeviceLabelAsync(cancellationToken);
AuthResultDto result = await _authClient.LoginAsync(new LoginRequest
{
Email = email,
Password = password,
TrustDevice = trustDevice,
DeviceFingerprint = fingerprint,
DeviceLabel = label
}, cancellationToken);
_current = new StoredAuthState
{
User = result.User,
Device = result.Device,
AuthSession = result.AuthSession,
Tokens = result.Tokens
};
await _tokenStore.SaveAsync(_current, cancellationToken);
StateChanged?.Invoke();
}
public async Task<bool> TryRefreshAsync(CancellationToken cancellationToken)
{
if (_current is null || string.IsNullOrWhiteSpace(_current.Tokens.RefreshToken))
{
await ClearInternalAsync(cancellationToken, true);
return false;
}
try
{
AuthResultDto result = await _authClient.RefreshAsync(new RefreshRequest
{
RefreshToken = _current.Tokens.RefreshToken
}, cancellationToken);
_current = new StoredAuthState
{
User = result.User,
Device = result.Device,
AuthSession = result.AuthSession,
Tokens = result.Tokens
};
await _tokenStore.SaveAsync(_current, cancellationToken);
StateChanged?.Invoke();
return true;
}
catch (Exception)
{
await ClearInternalAsync(cancellationToken, true);
return false;
}
}
public async Task LogoutAsync(CancellationToken cancellationToken)
{
if (_current is not null)
{
try
{
await _authClient.RevokeAuthSessionAsync(_current.User.ID, _current.AuthSession.ID, "client_logout", cancellationToken);
}
catch
{
}
}
await ClearInternalAsync(cancellationToken, false);
}
public Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_current?.Tokens.AccessToken);
}
private async Task ClearInternalAsync(CancellationToken cancellationToken, bool expired)
{
_current = null;
await _tokenStore.ClearAsync(cancellationToken);
StateChanged?.Invoke();
if (expired)
{
SessionExpired?.Invoke();
}
}
}
@@ -0,0 +1,25 @@
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Application.Services;
public sealed class OrganizationContextService(IBackendOrganizationClient organizationClient, ILocalSettingsStore settingsStore)
{
public async Task<IReadOnlyList<OrganizationDto>> LoadOrganizationsAsync(string userId, CancellationToken cancellationToken)
{
return await organizationClient.GetOrganizationsAsync(userId, cancellationToken);
}
public async Task<string?> GetSavedOrganizationIdAsync(string userId, CancellationToken cancellationToken)
{
LocalClientSettings settings = await settingsStore.LoadAsync(cancellationToken);
return settings.LastOrganizationByUserId.TryGetValue(userId, out string? organizationId) ? organizationId : null;
}
public async Task SaveOrganizationIdAsync(string userId, string organizationId, CancellationToken cancellationToken)
{
LocalClientSettings settings = await settingsStore.LoadAsync(cancellationToken);
settings.LastOrganizationByUserId[userId] = organizationId;
await settingsStore.SaveAsync(settings, cancellationToken);
}
}
@@ -0,0 +1,12 @@
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Application.Services;
public sealed class ResourceCatalogService(IBackendResourceClient resourceClient)
{
public Task<IReadOnlyList<ResourceDto>> LoadResourcesAsync(string userId, string organizationId, CancellationToken cancellationToken)
{
return resourceClient.GetResourcesAsync(userId, organizationId, cancellationToken);
}
}
@@ -0,0 +1,48 @@
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Application.Services;
public sealed class SessionService(IBackendSessionClient sessionClient)
{
public Task<IReadOnlyList<RemoteSessionDto>> LoadSessionsAsync(string userId, CancellationToken cancellationToken) =>
sessionClient.GetSessionsAsync(userId, cancellationToken);
public Task<SessionControlResultDto> StartSessionAsync(string resourceId, string userId, string deviceId, CancellationToken cancellationToken) =>
sessionClient.StartSessionAsync(new StartSessionRequest
{
ResourceId = resourceId,
UserId = userId,
DeviceId = deviceId
}, cancellationToken);
public Task<SessionControlResultDto> AttachSessionAsync(string sessionId, string userId, string deviceId, CancellationToken cancellationToken) =>
sessionClient.AttachSessionAsync(sessionId, new AttachSessionRequest
{
UserId = userId,
DeviceId = deviceId
}, cancellationToken);
public Task<SessionControlResultDto> DetachSessionAsync(string sessionId, string attachmentId, string userId, string reason, CancellationToken cancellationToken) =>
sessionClient.DetachSessionAsync(sessionId, new DetachSessionRequest
{
AttachmentId = attachmentId,
UserId = userId,
Reason = reason
}, cancellationToken);
public Task<SessionControlResultDto> TakeoverSessionAsync(string sessionId, string userId, string deviceId, string reason, CancellationToken cancellationToken) =>
sessionClient.TakeoverSessionAsync(sessionId, new TakeoverSessionRequest
{
UserId = userId,
DeviceId = deviceId,
Reason = reason
}, cancellationToken);
public Task TerminateSessionAsync(string sessionId, string userId, string reason, CancellationToken cancellationToken) =>
sessionClient.TerminateSessionAsync(sessionId, new TerminateSessionRequest
{
UserId = userId,
Reason = reason
}, cancellationToken);
}
@@ -0,0 +1,406 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using RemoteAccessPlatform.Windows.Application.Common;
using RemoteAccessPlatform.Windows.Application.Localization;
using RemoteAccessPlatform.Windows.Application.Services;
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Application.ViewModels;
public sealed class MainViewModel : ObservableObject
{
private readonly AuthenticationService _authenticationService;
private readonly OrganizationContextService _organizationContextService;
private readonly ResourceCatalogService _resourceCatalogService;
private readonly SessionService _sessionService;
private readonly Contracts.ISessionGatewayClient _sessionGatewayClient;
private string _email = string.Empty;
private string _password = string.Empty;
private bool _trustDevice = true;
private string _statusMessage = Strings.Get("status.ready");
private bool _isBusy;
private OrganizationDto? _selectedOrganization;
private ResourceDto? _selectedResource;
private RemoteSessionDto? _selectedSession;
public MainViewModel(
AuthenticationService authenticationService,
OrganizationContextService organizationContextService,
ResourceCatalogService resourceCatalogService,
SessionService sessionService,
Contracts.ISessionGatewayClient sessionGatewayClient)
{
_authenticationService = authenticationService;
_organizationContextService = organizationContextService;
_resourceCatalogService = resourceCatalogService;
_sessionService = sessionService;
_sessionGatewayClient = sessionGatewayClient;
LoginCommand = new AsyncRelayCommand(LoginAsync, () => !IsAuthenticated && !IsBusy);
LogoutCommand = new AsyncRelayCommand(LogoutAsync, () => IsAuthenticated && !IsBusy);
SwitchOrganizationCommand = new AsyncRelayCommand(SwitchOrganizationAsync, () => IsAuthenticated && SelectedOrganization is not null && !IsBusy);
RefreshResourcesCommand = new AsyncRelayCommand(RefreshResourcesAsync, () => IsAuthenticated && SelectedOrganization is not null && !IsBusy);
RefreshSessionsCommand = new AsyncRelayCommand(RefreshSessionsAsync, () => IsAuthenticated && !IsBusy);
StartSessionCommand = new AsyncRelayCommand<ResourceDto>(StartSessionAsync, resource => IsAuthenticated && resource is not null && !IsBusy);
AttachSessionCommand = new AsyncRelayCommand<RemoteSessionDto>(AttachSessionAsync, session => IsAuthenticated && session is not null && !IsBusy);
TakeOverSessionCommand = new AsyncRelayCommand<RemoteSessionDto>(TakeOverSessionAsync, session => IsAuthenticated && session is not null && !IsBusy);
TerminateSessionCommand = new AsyncRelayCommand<RemoteSessionDto>(TerminateSessionAsync, session => IsAuthenticated && session is not null && !IsBusy);
_authenticationService.StateChanged += HandleAuthenticationStateChanged;
_authenticationService.SessionExpired += HandleSessionExpired;
}
public event Action<SessionWindowViewModel>? SessionWindowRequested;
public ObservableCollection<OrganizationDto> Organizations { get; } = [];
public ObservableCollection<ResourceDto> Resources { get; } = [];
public ObservableCollection<RemoteSessionDto> Sessions { get; } = [];
public string Email
{
get => _email;
set => SetProperty(ref _email, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public bool TrustDevice
{
get => _trustDevice;
set => SetProperty(ref _trustDevice, value);
}
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
{
LoginCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged();
SwitchOrganizationCommand.NotifyCanExecuteChanged();
RefreshResourcesCommand.NotifyCanExecuteChanged();
RefreshSessionsCommand.NotifyCanExecuteChanged();
StartSessionCommand.NotifyCanExecuteChanged();
AttachSessionCommand.NotifyCanExecuteChanged();
TakeOverSessionCommand.NotifyCanExecuteChanged();
TerminateSessionCommand.NotifyCanExecuteChanged();
}
}
}
public bool IsAuthenticated => _authenticationService.IsAuthenticated;
public string CurrentUserEmail => _authenticationService.Current?.User.Email ?? string.Empty;
public OrganizationDto? SelectedOrganization
{
get => _selectedOrganization;
set
{
if (SetProperty(ref _selectedOrganization, value))
{
SwitchOrganizationCommand.NotifyCanExecuteChanged();
RefreshResourcesCommand.NotifyCanExecuteChanged();
}
}
}
public ResourceDto? SelectedResource
{
get => _selectedResource;
set
{
if (SetProperty(ref _selectedResource, value))
{
StartSessionCommand.NotifyCanExecuteChanged();
}
}
}
public RemoteSessionDto? SelectedSession
{
get => _selectedSession;
set
{
if (SetProperty(ref _selectedSession, value))
{
AttachSessionCommand.NotifyCanExecuteChanged();
TakeOverSessionCommand.NotifyCanExecuteChanged();
TerminateSessionCommand.NotifyCanExecuteChanged();
}
}
}
public AsyncRelayCommand LoginCommand { get; }
public AsyncRelayCommand LogoutCommand { get; }
public AsyncRelayCommand SwitchOrganizationCommand { get; }
public AsyncRelayCommand RefreshResourcesCommand { get; }
public AsyncRelayCommand RefreshSessionsCommand { get; }
public AsyncRelayCommand<ResourceDto> StartSessionCommand { get; }
public AsyncRelayCommand<RemoteSessionDto> AttachSessionCommand { get; }
public AsyncRelayCommand<RemoteSessionDto> TakeOverSessionCommand { get; }
public AsyncRelayCommand<RemoteSessionDto> TerminateSessionCommand { get; }
public async Task InitializeAsync()
{
await _authenticationService.InitializeAsync(CancellationToken.None);
if (IsAuthenticated)
{
await LoadWorkspaceAsync(CancellationToken.None);
}
}
private async Task LoginAsync()
{
await ExecuteBusyAsync(Strings.Get("status.auth.logging_in"), async cancellationToken =>
{
await _authenticationService.LoginAsync(Email, Password, TrustDevice, cancellationToken);
Password = string.Empty;
await LoadWorkspaceAsync(cancellationToken);
StatusMessage = Strings.Get("status.auth.logged_in");
});
}
private async Task LogoutAsync()
{
await ExecuteBusyAsync(Strings.Get("status.auth.logging_out"), async cancellationToken =>
{
await _authenticationService.LogoutAsync(cancellationToken);
ClearWorkspace();
StatusMessage = Strings.Get("status.auth.logged_out");
});
}
private async Task SwitchOrganizationAsync()
{
if (SelectedOrganization is null || _authenticationService.CurrentUserId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Get("status.organization.switching"), async cancellationToken =>
{
await _organizationContextService.SaveOrganizationIdAsync(_authenticationService.CurrentUserId, SelectedOrganization.Id, cancellationToken);
await RefreshResourcesAsync();
await RefreshSessionsAsync();
StatusMessage = Strings.Format("status.organization.active", SelectedOrganization.Name);
});
}
private async Task RefreshResourcesAsync()
{
if (SelectedOrganization is null || _authenticationService.CurrentUserId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Get("status.resources.refreshing"), async cancellationToken =>
{
IReadOnlyList<ResourceDto> resources = await _resourceCatalogService.LoadResourcesAsync(
_authenticationService.CurrentUserId,
SelectedOrganization.Id,
cancellationToken);
ReplaceCollection(Resources, resources);
StatusMessage = Strings.Format("status.resources.loaded", Resources.Count);
});
}
private async Task RefreshSessionsAsync()
{
if (_authenticationService.CurrentUserId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Get("status.sessions.refreshing"), async cancellationToken =>
{
IReadOnlyList<RemoteSessionDto> sessions = await _sessionService.LoadSessionsAsync(_authenticationService.CurrentUserId, cancellationToken);
ReplaceCollection(Sessions, sessions);
StatusMessage = Strings.Format("status.sessions.loaded", Sessions.Count);
});
}
private async Task StartSessionAsync(ResourceDto? resource)
{
if (resource is null || _authenticationService.CurrentUserId is null || _authenticationService.CurrentDeviceId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Format("status.session.starting", resource.Name), async cancellationToken =>
{
SessionControlResultDto result = await _sessionService.StartSessionAsync(
resource.Id,
_authenticationService.CurrentUserId,
_authenticationService.CurrentDeviceId,
cancellationToken);
await OpenSessionWindowAsync(result, "start");
await RefreshSessionsAsync();
});
}
private async Task AttachSessionAsync(RemoteSessionDto? session)
{
if (session is null || _authenticationService.CurrentUserId is null || _authenticationService.CurrentDeviceId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Format("status.session.attaching", session.ID), async cancellationToken =>
{
SessionControlResultDto result = await _sessionService.AttachSessionAsync(
session.ID,
_authenticationService.CurrentUserId,
_authenticationService.CurrentDeviceId,
cancellationToken);
await OpenSessionWindowAsync(result, "attach");
await RefreshSessionsAsync();
});
}
private async Task TakeOverSessionAsync(RemoteSessionDto? session)
{
if (session is null || _authenticationService.CurrentUserId is null || _authenticationService.CurrentDeviceId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Format("status.session.taking_over", session.ID), async cancellationToken =>
{
SessionControlResultDto result = await _sessionService.TakeoverSessionAsync(
session.ID,
_authenticationService.CurrentUserId,
_authenticationService.CurrentDeviceId,
"windows_client_takeover",
cancellationToken);
await OpenSessionWindowAsync(result, "takeover");
await RefreshSessionsAsync();
});
}
private async Task TerminateSessionAsync(RemoteSessionDto? session)
{
if (session is null || _authenticationService.CurrentUserId is null)
{
return;
}
await ExecuteBusyAsync(Strings.Format("status.session.terminating", session.ID), async cancellationToken =>
{
await _sessionService.TerminateSessionAsync(session.ID, _authenticationService.CurrentUserId, "windows_client_terminate", cancellationToken);
await RefreshSessionsAsync();
StatusMessage = Strings.Format("status.session.terminated", session.ID);
});
}
private async Task LoadWorkspaceAsync(CancellationToken cancellationToken)
{
if (_authenticationService.CurrentUserId is null)
{
return;
}
IReadOnlyList<OrganizationDto> organizations = await _organizationContextService.LoadOrganizationsAsync(_authenticationService.CurrentUserId, cancellationToken);
ReplaceCollection(Organizations, organizations);
string? savedOrganizationId = await _organizationContextService.GetSavedOrganizationIdAsync(_authenticationService.CurrentUserId, cancellationToken);
SelectedOrganization = Organizations.FirstOrDefault(x => x.Id == savedOrganizationId) ?? Organizations.FirstOrDefault();
if (SelectedOrganization is not null)
{
await _organizationContextService.SaveOrganizationIdAsync(_authenticationService.CurrentUserId, SelectedOrganization.Id, cancellationToken);
IReadOnlyList<ResourceDto> resources = await _resourceCatalogService.LoadResourcesAsync(_authenticationService.CurrentUserId, SelectedOrganization.Id, cancellationToken);
ReplaceCollection(Resources, resources);
}
else
{
Resources.Clear();
}
IReadOnlyList<RemoteSessionDto> sessions = await _sessionService.LoadSessionsAsync(_authenticationService.CurrentUserId, cancellationToken);
ReplaceCollection(Sessions, sessions);
RaisePropertyChanged(nameof(CurrentUserEmail));
RaisePropertyChanged(nameof(IsAuthenticated));
StatusMessage = Strings.Get("status.workspace.ready");
}
private async Task OpenSessionWindowAsync(SessionControlResultDto result, string launchReason)
{
Trace.WriteLine($"[{DateTimeOffset.Now:O}] [T{Environment.CurrentManagedThreadId}] MainViewModel.OpenSessionWindowAsync start: reason={launchReason}, session={result.Session.ID}");
var vm = new SessionWindowViewModel(_sessionService, _sessionGatewayClient, _authenticationService, result, launchReason);
SessionWindowRequested?.Invoke(vm);
Trace.WriteLine($"[{DateTimeOffset.Now:O}] [T{Environment.CurrentManagedThreadId}] MainViewModel.OpenSessionWindowAsync event invoked: reason={launchReason}, session={result.Session.ID}");
await Task.CompletedTask;
}
private void HandleAuthenticationStateChanged()
{
RaisePropertyChanged(nameof(IsAuthenticated));
RaisePropertyChanged(nameof(CurrentUserEmail));
LoginCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged();
}
private void HandleSessionExpired()
{
ClearWorkspace();
StatusMessage = Strings.Get("status.auth.session_expired");
}
private void ClearWorkspace()
{
Organizations.Clear();
Resources.Clear();
Sessions.Clear();
SelectedOrganization = null;
SelectedResource = null;
SelectedSession = null;
RaisePropertyChanged(nameof(IsAuthenticated));
RaisePropertyChanged(nameof(CurrentUserEmail));
}
private async Task ExecuteBusyAsync(string status, Func<CancellationToken, Task> action)
{
IsBusy = true;
StatusMessage = status;
try
{
await action(CancellationToken.None);
}
catch (Exception ex) when (ex is IBackendMessageException backendMessage)
{
StatusMessage = Strings.ResolveBackendMessage(backendMessage.MessageKey, backendMessage.FallbackMessage, backendMessage.Code);
}
catch (Exception)
{
StatusMessage = Strings.Get("errors.common.unexpected");
}
finally
{
IsBusy = false;
}
}
private static void ReplaceCollection<T>(ObservableCollection<T> target, IEnumerable<T> source)
{
target.Clear();
foreach (T item in source)
{
target.Add(item);
}
}
}
@@ -0,0 +1,10 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IBackendAuthClient
{
Task<AuthResultDto> LoginAsync(LoginRequest request, CancellationToken cancellationToken);
Task<AuthResultDto> RefreshAsync(RefreshRequest request, CancellationToken cancellationToken);
Task RevokeAuthSessionAsync(string userId, string authSessionId, string reason, CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IBackendMessageException
{
string Code { get; }
string MessageKey { get; }
string FallbackMessage { get; }
string? TraceId { get; }
}
@@ -0,0 +1,8 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IBackendOrganizationClient
{
Task<IReadOnlyList<OrganizationDto>> GetOrganizationsAsync(string userId, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IBackendResourceClient
{
Task<IReadOnlyList<ResourceDto>> GetResourcesAsync(string userId, string organizationId, CancellationToken cancellationToken);
}
@@ -0,0 +1,13 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IBackendSessionClient
{
Task<IReadOnlyList<RemoteSessionDto>> GetSessionsAsync(string userId, CancellationToken cancellationToken);
Task<SessionControlResultDto> StartSessionAsync(StartSessionRequest request, CancellationToken cancellationToken);
Task<SessionControlResultDto> AttachSessionAsync(string sessionId, AttachSessionRequest request, CancellationToken cancellationToken);
Task<SessionControlResultDto> DetachSessionAsync(string sessionId, DetachSessionRequest request, CancellationToken cancellationToken);
Task<SessionControlResultDto> TakeoverSessionAsync(string sessionId, TakeoverSessionRequest request, CancellationToken cancellationToken);
Task TerminateSessionAsync(string sessionId, TerminateSessionRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,7 @@
namespace RemoteAccessPlatform.Windows.Contracts;
public interface IDeviceIdentityProvider
{
Task<string> GetOrCreateFingerprintAsync(CancellationToken cancellationToken);
Task<string> GetDeviceLabelAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface ILocalSettingsStore
{
Task<LocalClientSettings> LoadAsync(CancellationToken cancellationToken);
Task SaveAsync(LocalClientSettings settings, CancellationToken cancellationToken);
}
@@ -0,0 +1,27 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface ISessionGatewayClient
{
Task<ISessionGatewayConnection> ConnectAsync(
string attachToken,
DataPlaneOfferDto? dataPlane,
Func<LiveTransportEnvelopeDto, Task> onEnvelope,
Func<Exception?, Task> onClosed,
CancellationToken cancellationToken);
}
public interface ISessionGatewayConnection : IAsyncDisposable
{
bool IsConnected { get; }
string TransportKind { get; }
Task SendInputAsync(SessionInputEventDto inputEvent, CancellationToken cancellationToken);
Task SendClipboardTextAsync(string text, string origin, string contentHash, long sequenceId, CancellationToken cancellationToken);
Task SendFileUploadStartAsync(FileUploadStartDto upload, CancellationToken cancellationToken);
Task SendFileUploadChunkAsync(FileUploadChunkDto chunk, CancellationToken cancellationToken);
Task SendFileUploadCancelAsync(string transferId, CancellationToken cancellationToken);
Task SendFileDownloadStartAsync(FileDownloadStartDto download, CancellationToken cancellationToken);
Task SendFileDownloadAckAsync(FileDownloadAckDto ack, CancellationToken cancellationToken);
Task SendFileDownloadCancelAsync(string transferId, CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Contracts;
public interface ITokenStore
{
Task<StoredAuthState?> LoadAsync(CancellationToken cancellationToken);
Task SaveAsync(StoredAuthState state, CancellationToken cancellationToken);
Task ClearAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class LoginRequest
{
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
[JsonPropertyName("device_fingerprint")]
public string DeviceFingerprint { get; set; } = string.Empty;
[JsonPropertyName("device_label")]
public string DeviceLabel { get; set; } = string.Empty;
[JsonPropertyName("trust_device")]
public bool TrustDevice { get; set; }
}
public sealed class RefreshRequest
{
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
}
public sealed class UserDto
{
public string ID { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
public sealed class DeviceDto
{
public string ID { get; set; } = string.Empty;
public string UserID { get; set; } = string.Empty;
public string Fingerprint { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string TrustStatus { get; set; } = string.Empty;
}
public sealed class AuthSessionDto
{
public string ID { get; set; } = string.Empty;
public string UserID { get; set; } = string.Empty;
public string DeviceID { get; set; } = string.Empty;
}
public sealed class TokenPairDto
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("access_token_expires_at")]
public DateTimeOffset AccessTokenExpiresAt { get; set; }
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
[JsonPropertyName("refresh_token_expires_at")]
public DateTimeOffset RefreshTokenExpiresAt { get; set; }
}
public sealed class AuthResultDto
{
[JsonPropertyName("user")]
public UserDto User { get; set; } = new();
[JsonPropertyName("device")]
public DeviceDto Device { get; set; } = new();
[JsonPropertyName("auth_session")]
public AuthSessionDto AuthSession { get; set; } = new();
[JsonPropertyName("tokens")]
public TokenPairDto Tokens { get; set; } = new();
}
public sealed class StoredAuthState
{
public UserDto User { get; set; } = new();
public DeviceDto Device { get; set; } = new();
public AuthSessionDto AuthSession { get; set; } = new();
public TokenPairDto Tokens { get; set; } = new();
}
@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class LocalizedMessageDto
{
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
[JsonPropertyName("message_key")]
public string MessageKey { get; set; } = string.Empty;
[JsonPropertyName("fallback_message")]
public string FallbackMessage { get; set; } = string.Empty;
[JsonPropertyName("details")]
public Dictionary<string, JsonElement>? Details { get; set; }
[JsonPropertyName("trace_id")]
public string? TraceId { get; set; }
}
public sealed class BackendErrorEnvelopeDto
{
[JsonPropertyName("error")]
public LocalizedMessageDto Error { get; set; } = new();
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class OrganizationDto
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("slug")]
public string Slug { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
}
public sealed class OrganizationsResponse
{
[JsonPropertyName("organizations")]
public List<OrganizationDto> Organizations { get; set; } = [];
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class ResourceDto
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("organization_id")]
public string OrganizationId { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("address")]
public string Address { get; set; } = string.Empty;
[JsonPropertyName("protocol")]
public string Protocol { get; set; } = string.Empty;
[JsonPropertyName("certificate_verification_mode")]
public string CertificateVerificationMode { get; set; } = "strict";
[JsonPropertyName("clipboard_mode")]
public string ClipboardMode { get; set; } = "disabled";
[JsonPropertyName("file_transfer_mode")]
public string FileTransferMode { get; set; } = "disabled";
[JsonPropertyName("metadata")]
public Dictionary<string, object?> Metadata { get; set; } = new();
}
public sealed class ResourcesResponse
{
[JsonPropertyName("resources")]
public List<ResourceDto> Resources { get; set; } = [];
}
@@ -0,0 +1,443 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class StartSessionRequest
{
[JsonPropertyName("resource_id")]
public string ResourceId { get; set; } = string.Empty;
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public string DeviceId { get; set; } = string.Empty;
}
public sealed class AttachSessionRequest
{
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public string DeviceId { get; set; } = string.Empty;
}
public sealed class DetachSessionRequest
{
[JsonPropertyName("attachment_id")]
public string AttachmentId { get; set; } = string.Empty;
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
public sealed class TakeoverSessionRequest
{
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
public sealed class TerminateSessionRequest
{
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
public sealed class RemoteSessionDto
{
public string ID { get; set; } = string.Empty;
public string OrganizationID { get; set; } = string.Empty;
public string ResourceID { get; set; } = string.Empty;
public string Protocol { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string WorkerID { get; set; } = string.Empty;
public string ControllerUserID { get; set; } = string.Empty;
public DateTimeOffset? DetachDeadlineAt { get; set; }
public DateTimeOffset? LastHeartbeatAt { get; set; }
public int TakeoverVersion { get; set; }
}
public sealed class SessionAttachmentDto
{
public string ID { get; set; } = string.Empty;
public string RemoteSessionID { get; set; } = string.Empty;
public string UserID { get; set; } = string.Empty;
public string DeviceID { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string? SupersededBy { get; set; }
public string? TakeoverOf { get; set; }
}
public sealed class AttachTokenDto
{
[JsonPropertyName("token")]
public string Token { get; set; } = string.Empty;
[JsonPropertyName("session_id")]
public string SessionId { get; set; } = string.Empty;
[JsonPropertyName("attachment_id")]
public string AttachmentId { get; set; } = string.Empty;
[JsonPropertyName("user_id")]
public string UserId { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public string DeviceId { get; set; } = string.Empty;
[JsonPropertyName("worker_id")]
public string WorkerId { get; set; } = string.Empty;
[JsonPropertyName("takeover_version")]
public int TakeoverVersion { get; set; }
[JsonPropertyName("expires_at")]
public DateTimeOffset ExpiresAt { get; set; }
[JsonPropertyName("reconnectable")]
public bool Reconnectable { get; set; }
}
public sealed class SessionControlResultDto
{
[JsonPropertyName("session")]
public RemoteSessionDto Session { get; set; } = new();
[JsonPropertyName("attachment")]
public SessionAttachmentDto? Attachment { get; set; }
[JsonPropertyName("attach_token")]
public AttachTokenDto? AttachToken { get; set; }
[JsonPropertyName("gateway_url")]
public string? GatewayUrl { get; set; }
[JsonPropertyName("data_plane")]
public DataPlaneOfferDto? DataPlane { get; set; }
}
public sealed class DataPlaneOfferDto
{
[JsonPropertyName("preferred")]
public string Preferred { get; set; } = string.Empty;
[JsonPropertyName("token")]
public string Token { get; set; } = string.Empty;
[JsonPropertyName("expires_at")]
public DateTimeOffset ExpiresAt { get; set; }
[JsonPropertyName("candidates")]
public List<DataPlaneCandidateDto> Candidates { get; set; } = [];
}
public sealed class DataPlaneCandidateDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonPropertyName("worker_id")]
public string? WorkerId { get; set; }
[JsonPropertyName("priority")]
public int? Priority { get; set; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonElement>? Metadata { get; set; }
}
public sealed class SessionsResponse
{
[JsonPropertyName("sessions")]
public List<RemoteSessionDto> Sessions { get; set; } = [];
}
public sealed class LiveTransportEnvelopeDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("session_id")]
public string? SessionId { get; set; }
[JsonPropertyName("payload")]
public Dictionary<string, JsonElement>? Payload { get; set; }
[JsonPropertyName("event")]
public LocalizedMessageDto? Event { get; set; }
[JsonPropertyName("render_transport")]
public string? RenderTransport { get; set; }
[JsonPropertyName("color_mode")]
public string? ColorMode { get; set; }
[JsonIgnore]
public SessionFrameDto? BinaryFrame { get; set; }
}
public sealed class SessionRenderStateDto
{
[JsonPropertyName("quality_profile")]
public string QualityProfile { get; set; } = string.Empty;
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("width")]
public int? Width { get; set; }
[JsonPropertyName("height")]
public int? Height { get; set; }
[JsonPropertyName("cursor_x")]
public int? CursorX { get; set; }
[JsonPropertyName("cursor_y")]
public int? CursorY { get; set; }
[JsonPropertyName("cursor_visible")]
public bool? CursorVisible { get; set; }
[JsonPropertyName("dirty_rectangles")]
public int? DirtyRectangles { get; set; }
[JsonPropertyName("last_render_at")]
public DateTimeOffset? LastRenderAt { get; set; }
}
public sealed class SessionFrameDto
{
[JsonPropertyName("message_type")]
public string? MessageType { get; set; }
[JsonPropertyName("frame_sequence")]
public long FrameSequence { get; set; }
[JsonPropertyName("frame_width")]
public int Width { get; set; }
[JsonPropertyName("frame_height")]
public int Height { get; set; }
[JsonPropertyName("frame_stride")]
public int Stride { get; set; }
[JsonPropertyName("frame_format")]
public string PixelFormat { get; set; } = string.Empty;
[JsonPropertyName("frame_update_kind")]
public string UpdateKind { get; set; } = "full";
[JsonPropertyName("desktop_width")]
public int DesktopWidth { get; set; }
[JsonPropertyName("desktop_height")]
public int DesktopHeight { get; set; }
[JsonPropertyName("region_x")]
public int RegionX { get; set; }
[JsonPropertyName("region_y")]
public int RegionY { get; set; }
[JsonPropertyName("region_width")]
public int RegionWidth { get; set; }
[JsonPropertyName("region_height")]
public int RegionHeight { get; set; }
[JsonPropertyName("region_stride")]
public int RegionStride { get; set; }
[JsonPropertyName("region_format")]
public string? RegionFormat { get; set; }
[JsonPropertyName("color_mode")]
public string ColorMode { get; set; } = "full_color";
[JsonPropertyName("quality_profile")]
public string? QualityProfile { get; set; }
[JsonPropertyName("original_frame_format")]
public string? OriginalFrameFormat { get; set; }
[JsonPropertyName("output_frame_format")]
public string? OutputFrameFormat { get; set; }
[JsonPropertyName("raw_frame_bytes")]
public long? RawFrameBytes { get; set; }
[JsonPropertyName("binary_direct_bytes")]
public long? BinaryDirectBytes { get; set; }
[JsonPropertyName("full_frame_bytes")]
public long? FullFrameBytes { get; set; }
[JsonPropertyName("region_bytes")]
public long? RegionBytes { get; set; }
[JsonPropertyName("region_savings_percent")]
public double? RegionSavingsPercent { get; set; }
[JsonPropertyName("diff_time_ms")]
public long? DiffTimeMs { get; set; }
[JsonPropertyName("render_update_reason")]
public string? RenderUpdateReason { get; set; }
[JsonPropertyName("fallback_to_full_frame_reason")]
public string? FallbackToFullFrameReason { get; set; }
[JsonPropertyName("frame_data")]
public byte[] Pixels { get; set; } = [];
[JsonPropertyName("input_correlation_id")]
public string? InputCorrelationId { get; set; }
[JsonPropertyName("worker_frame_captured_at")]
public string? WorkerFrameCapturedAt { get; set; }
}
public sealed class SessionInputEventDto
{
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; set; }
[JsonPropertyName("client_captured_at")]
public string? ClientCapturedAt { get; set; }
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("action")]
public string Action { get; set; } = string.Empty;
[JsonPropertyName("scan_code")]
public uint? ScanCode { get; set; }
[JsonPropertyName("is_extended")]
public bool? IsExtended { get; set; }
[JsonPropertyName("button")]
public string? Button { get; set; }
[JsonPropertyName("normalized_x")]
public double? NormalizedX { get; set; }
[JsonPropertyName("normalized_y")]
public double? NormalizedY { get; set; }
[JsonPropertyName("wheel_delta")]
public int? WheelDelta { get; set; }
[JsonPropertyName("is_horizontal_wheel")]
public bool? IsHorizontalWheel { get; set; }
[JsonPropertyName("focused")]
public bool? Focused { get; set; }
[JsonPropertyName("surface_width")]
public double? SurfaceWidth { get; set; }
[JsonPropertyName("surface_height")]
public double? SurfaceHeight { get; set; }
}
public sealed class FileUploadStartDto
{
[JsonPropertyName("direction")]
public string Direction { get; set; } = "client_to_server";
[JsonPropertyName("transfer_id")]
public string TransferId { get; set; } = string.Empty;
[JsonPropertyName("file_name")]
public string FileName { get; set; } = string.Empty;
[JsonPropertyName("file_size")]
public long FileSize { get; set; }
[JsonPropertyName("total_chunks")]
public long TotalChunks { get; set; }
[JsonPropertyName("content_hash")]
public string ContentHash { get; set; } = string.Empty;
}
public sealed class FileUploadChunkDto
{
[JsonPropertyName("direction")]
public string Direction { get; set; } = "client_to_server";
[JsonPropertyName("transfer_id")]
public string TransferId { get; set; } = string.Empty;
[JsonPropertyName("chunk_index")]
public long ChunkIndex { get; set; }
[JsonPropertyName("offset")]
public long Offset { get; set; }
[JsonPropertyName("chunk_size")]
public int ChunkSize { get; set; }
[JsonPropertyName("chunk_bytes")]
public string ChunkBytes { get; set; } = string.Empty;
}
public sealed class FileDownloadStartDto
{
[JsonPropertyName("direction")]
public string Direction { get; set; } = "server_to_client";
[JsonPropertyName("transfer_id")]
public string TransferId { get; set; } = string.Empty;
[JsonPropertyName("file_id")]
public string FileId { get; set; } = string.Empty;
}
public sealed class FileDownloadAckDto
{
[JsonPropertyName("direction")]
public string Direction { get; set; } = "server_to_client";
[JsonPropertyName("transfer_id")]
public string TransferId { get; set; } = string.Empty;
[JsonPropertyName("file_id")]
public string FileId { get; set; } = string.Empty;
[JsonPropertyName("sequence")]
public long Sequence { get; set; }
[JsonPropertyName("offset")]
public long Offset { get; set; }
}
public sealed class FileDownloadCandidateDto
{
public string FileId { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentHash { get; set; } = string.Empty;
}
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace RemoteAccessPlatform.Windows.Models;
public sealed class BackendEndpointOptions
{
[JsonPropertyName("api_base_url")]
public string ApiBaseUrl { get; set; } = "http://192.168.200.61:8080/api/v1";
[JsonPropertyName("gateway_websocket_url")]
public string GatewayWebSocketUrl { get; set; } = "ws://192.168.200.61:8080/api/v1/gateway/ws";
[JsonPropertyName("prefer_direct_data_plane")]
public bool PreferDirectDataPlane { get; set; } = true;
[JsonPropertyName("direct_data_plane_connect_timeout_ms")]
public int DirectDataPlaneConnectTimeoutMs { get; set; } = 750;
[JsonPropertyName("allow_insecure_direct_data_plane_tls_for_smoke")]
public bool AllowInsecureDirectDataPlaneTlsForSmoke { get; set; }
[JsonPropertyName("direct_data_plane_color_mode")]
public string DirectDataPlaneColorMode { get; set; } = "full_color";
[JsonPropertyName("direct_data_plane_platform_ca_bundle")]
public string? DirectDataPlanePlatformCaBundle { get; set; }
[JsonPropertyName("environment")]
public string Environment { get; set; } = "development";
}
public sealed class LocalClientSettings
{
[JsonPropertyName("device_fingerprint")]
public string? DeviceFingerprint { get; set; }
[JsonPropertyName("last_organization_by_user_id")]
public Dictionary<string, string> LastOrganizationByUserId { get; set; } = new();
}
@@ -0,0 +1,129 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Settings;
public sealed class DpapiTokenStore : ITokenStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public async Task<StoredAuthState?> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(StoragePathResolver.TokenFilePath))
{
return null;
}
byte[] protectedPayload = await File.ReadAllBytesAsync(StoragePathResolver.TokenFilePath, cancellationToken);
byte[] payload = NativeDpapi.Unprotect(protectedPayload);
return JsonSerializer.Deserialize<StoredAuthState>(payload, SerializerOptions);
}
public async Task SaveAsync(StoredAuthState state, CancellationToken cancellationToken)
{
StoragePathResolver.EnsureRoot();
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(state, SerializerOptions);
byte[] protectedPayload = NativeDpapi.Protect(payload);
await File.WriteAllBytesAsync(StoragePathResolver.TokenFilePath, protectedPayload, cancellationToken);
}
public Task ClearAsync(CancellationToken cancellationToken)
{
if (File.Exists(StoragePathResolver.TokenFilePath))
{
File.Delete(StoragePathResolver.TokenFilePath);
}
return Task.CompletedTask;
}
private static class NativeDpapi
{
private const int CryptProtectUiForbidden = 0x1;
public static byte[] Protect(byte[] data)
{
return Execute(data, true);
}
public static byte[] Unprotect(byte[] data)
{
return Execute(data, false);
}
private static byte[] Execute(byte[] data, bool protect)
{
DATA_BLOB input = default;
DATA_BLOB output = default;
IntPtr inputPointer = IntPtr.Zero;
try
{
inputPointer = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, inputPointer, data.Length);
input.cbData = data.Length;
input.pbData = inputPointer;
bool success = protect
? CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, ref output)
: CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, ref output);
if (!success)
{
throw new InvalidOperationException($"DPAPI operation failed with Win32 error {Marshal.GetLastWin32Error()}.");
}
byte[] result = new byte[output.cbData];
Marshal.Copy(output.pbData, result, 0, output.cbData);
return result;
}
finally
{
if (inputPointer != IntPtr.Zero)
{
Marshal.FreeHGlobal(inputPointer);
}
if (output.pbData != IntPtr.Zero)
{
LocalFree(output.pbData);
}
}
}
[StructLayout(LayoutKind.Sequential)]
private struct DATA_BLOB
{
public int cbData;
public IntPtr pbData;
}
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptProtectData(
ref DATA_BLOB pDataIn,
string? szDataDescr,
IntPtr pOptionalEntropy,
IntPtr pvReserved,
IntPtr pPromptStruct,
int dwFlags,
ref DATA_BLOB pDataOut);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptUnprotectData(
ref DATA_BLOB pDataIn,
IntPtr ppszDataDescr,
IntPtr pOptionalEntropy,
IntPtr pvReserved,
IntPtr pPromptStruct,
int dwFlags,
ref DATA_BLOB pDataOut);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr hMem);
}
}
@@ -0,0 +1,32 @@
using System.Text.Json;
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Settings;
public sealed class JsonLocalSettingsStore : ILocalSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public async Task<LocalClientSettings> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(StoragePathResolver.SettingsFilePath))
{
return new LocalClientSettings();
}
await using FileStream stream = File.OpenRead(StoragePathResolver.SettingsFilePath);
LocalClientSettings? settings = await JsonSerializer.DeserializeAsync<LocalClientSettings>(stream, SerializerOptions, cancellationToken);
return settings ?? new LocalClientSettings();
}
public async Task SaveAsync(LocalClientSettings settings, CancellationToken cancellationToken)
{
StoragePathResolver.EnsureRoot();
await using FileStream stream = File.Create(StoragePathResolver.SettingsFilePath);
await JsonSerializer.SerializeAsync(stream, settings, SerializerOptions, cancellationToken);
}
}
@@ -0,0 +1,24 @@
using RemoteAccessPlatform.Windows.Contracts;
namespace RemoteAccessPlatform.Windows.Settings;
public sealed class LocalDeviceIdentityProvider(ILocalSettingsStore settingsStore) : IDeviceIdentityProvider
{
public async Task<string> GetOrCreateFingerprintAsync(CancellationToken cancellationToken)
{
var settings = await settingsStore.LoadAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(settings.DeviceFingerprint))
{
return settings.DeviceFingerprint;
}
settings.DeviceFingerprint = Guid.NewGuid().ToString("N");
await settingsStore.SaveAsync(settings, cancellationToken);
return settings.DeviceFingerprint;
}
public Task<string> GetDeviceLabelAsync(CancellationToken cancellationToken)
{
return Task.FromResult(Environment.MachineName);
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Contracts\RemoteAccessPlatform.Windows.Contracts.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,18 @@
namespace RemoteAccessPlatform.Windows.Settings;
internal static class StoragePathResolver
{
private static readonly string Root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RemoteAccessPlatform",
"WindowsClient");
public static string RootDirectory => Root;
public static string TokenFilePath => Path.Combine(Root, "auth-state.dat");
public static string SettingsFilePath => Path.Combine(Root, "client-settings.json");
public static void EnsureRoot()
{
Directory.CreateDirectory(Root);
}
}
@@ -0,0 +1,259 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using RemoteAccessPlatform.Windows.Contracts;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Transport;
public sealed class BackendApiClient : IBackendAuthClient, IBackendOrganizationClient, IBackendResourceClient, IBackendSessionClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly BackendEndpointOptions _options;
private readonly Func<CancellationToken, Task<string?>> _accessTokenProvider;
private readonly Func<CancellationToken, Task<bool>> _tryRefreshAsync;
public BackendApiClient(
HttpClient httpClient,
BackendEndpointOptions options,
Func<CancellationToken, Task<string?>> accessTokenProvider,
Func<CancellationToken, Task<bool>> tryRefreshAsync)
{
_httpClient = httpClient;
_options = options;
_accessTokenProvider = accessTokenProvider;
_tryRefreshAsync = tryRefreshAsync;
}
public Task<AuthResultDto> LoginAsync(LoginRequest request, CancellationToken cancellationToken) =>
SendAsync<AuthResultDto>(HttpMethod.Post, "auth/login", request, null, false, cancellationToken);
public Task<AuthResultDto> RefreshAsync(RefreshRequest request, CancellationToken cancellationToken) =>
SendAsync<AuthResultDto>(HttpMethod.Post, "auth/refresh", request, null, false, cancellationToken);
public Task RevokeAuthSessionAsync(string userId, string authSessionId, string reason, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, "auth/sessions/revoke", new
{
user_id = userId,
auth_session_id = authSessionId,
reason
}, null, true, cancellationToken);
public async Task<IReadOnlyList<OrganizationDto>> GetOrganizationsAsync(string userId, CancellationToken cancellationToken)
{
OrganizationsResponse response = await SendAsync<OrganizationsResponse>(HttpMethod.Get, "organizations", null, new Dictionary<string, string?>
{
["user_id"] = userId
}, true, cancellationToken);
return response.Organizations;
}
public async Task<IReadOnlyList<ResourceDto>> GetResourcesAsync(string userId, string organizationId, CancellationToken cancellationToken)
{
ResourcesResponse response = await SendAsync<ResourcesResponse>(HttpMethod.Get, "resources", null, new Dictionary<string, string?>
{
["user_id"] = userId,
["organization_id"] = organizationId
}, true, cancellationToken);
return response.Resources;
}
public async Task<IReadOnlyList<RemoteSessionDto>> GetSessionsAsync(string userId, CancellationToken cancellationToken)
{
SessionsResponse response = await SendAsync<SessionsResponse>(HttpMethod.Get, "sessions", null, new Dictionary<string, string?>
{
["user_id"] = userId
}, true, cancellationToken);
return response.Sessions;
}
public Task<SessionControlResultDto> StartSessionAsync(StartSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, "sessions", request, null, true, cancellationToken);
public Task<SessionControlResultDto> AttachSessionAsync(string sessionId, AttachSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/attach", request, null, true, cancellationToken);
public Task<SessionControlResultDto> DetachSessionAsync(string sessionId, DetachSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/detach", request, null, true, cancellationToken);
public Task<SessionControlResultDto> TakeoverSessionAsync(string sessionId, TakeoverSessionRequest request, CancellationToken cancellationToken) =>
SendAsync<SessionControlResultDto>(HttpMethod.Post, $"sessions/{sessionId}/takeover", request, null, true, cancellationToken);
public Task TerminateSessionAsync(string sessionId, TerminateSessionRequest request, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, $"sessions/{sessionId}/terminate", request, null, true, cancellationToken);
private Task SendAsync(HttpMethod method, string path, object? body, IDictionary<string, string?>? query, bool authorized, CancellationToken cancellationToken) =>
SendAsync<object?>(method, path, body, query, authorized, cancellationToken);
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
HttpResponseMessage response = await SendCoreAsync(method, path, body, query, authorized, cancellationToken);
if (typeof(T) == typeof(object))
{
return default!;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
T? payload = await JsonSerializer.DeserializeAsync<T>(stream, SerializerOptions, cancellationToken);
return payload ?? throw new BackendApiException(response.StatusCode, new LocalizedMessageDto
{
Code = "common.empty_response_payload",
MessageKey = "errors.common.unexpected",
FallbackMessage = "Something went wrong. Please try again."
});
}
private async Task<HttpResponseMessage> SendCoreAsync(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
HttpResponseMessage response = await SendOnceAsync(method, path, body, query, authorized, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized || !authorized)
{
return await EnsureSuccessAsync(response, cancellationToken);
}
response.Dispose();
bool refreshed = await _tryRefreshAsync(cancellationToken);
if (!refreshed)
{
throw new BackendApiException(HttpStatusCode.Unauthorized, new LocalizedMessageDto
{
Code = "auth.session_expired",
MessageKey = "errors.auth.session_expired",
FallbackMessage = "Session expired. Please sign in again."
});
}
response = await SendOnceAsync(method, path, body, query, authorized, cancellationToken);
return await EnsureSuccessAsync(response, cancellationToken);
}
private async Task<HttpResponseMessage> SendOnceAsync(
HttpMethod method,
string path,
object? body,
IDictionary<string, string?>? query,
bool authorized,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, BuildUri(path, query));
if (body is not null)
{
string json = JsonSerializer.Serialize(body, SerializerOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
if (authorized)
{
string? accessToken = await _accessTokenProvider(cancellationToken);
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
}
return await _httpClient.SendAsync(request, cancellationToken);
}
private static async Task<HttpResponseMessage> EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return response;
}
string content = await response.Content.ReadAsStringAsync(cancellationToken);
LocalizedMessageDto error = TryExtractError(content) ?? new LocalizedMessageDto
{
Code = "common.request_failed",
MessageKey = "errors.common.unexpected",
FallbackMessage = response.ReasonPhrase ?? "Request failed."
};
throw new BackendApiException(response.StatusCode, error);
}
private Uri BuildUri(string path, IDictionary<string, string?>? query)
{
var builder = new StringBuilder(_options.ApiBaseUrl.TrimEnd('/')).Append('/').Append(path.TrimStart('/'));
if (query is { Count: > 0 })
{
string separator = "?";
foreach ((string key, string? value) in query.Where(pair => !string.IsNullOrWhiteSpace(pair.Value)))
{
builder.Append(separator)
.Append(Uri.EscapeDataString(key))
.Append('=')
.Append(Uri.EscapeDataString(value!));
separator = "&";
}
}
return new Uri(builder.ToString(), UriKind.Absolute);
}
private static LocalizedMessageDto? TryExtractError(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(content);
if (document.RootElement.TryGetProperty("error", out JsonElement errorElement))
{
if (errorElement.ValueKind == JsonValueKind.Object)
{
return JsonSerializer.Deserialize<LocalizedMessageDto>(errorElement.GetRawText(), SerializerOptions);
}
if (errorElement.ValueKind == JsonValueKind.String)
{
return new LocalizedMessageDto
{
Code = "common.legacy_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = errorElement.GetString() ?? "Request failed."
};
}
}
}
catch (JsonException)
{
return new LocalizedMessageDto
{
Code = "common.raw_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = content
};
}
return new LocalizedMessageDto
{
Code = "common.raw_error",
MessageKey = "errors.common.unexpected",
FallbackMessage = content
};
}
}
@@ -0,0 +1,24 @@
using RemoteAccessPlatform.Windows.Contracts;
using System.Net;
using RemoteAccessPlatform.Windows.Models;
namespace RemoteAccessPlatform.Windows.Transport;
public sealed class BackendApiException : Exception, IBackendMessageException
{
public BackendApiException(HttpStatusCode statusCode, LocalizedMessageDto error)
: base(error.FallbackMessage)
{
StatusCode = statusCode;
Error = error;
}
public HttpStatusCode StatusCode { get; }
public LocalizedMessageDto Error { get; }
public string Code => Error.Code;
public string MessageKey => Error.MessageKey;
public string FallbackMessage => Error.FallbackMessage;
public string? TraceId => Error.TraceId;
public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized;
public bool IsForbidden => StatusCode == HttpStatusCode.Forbidden;
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Contracts\RemoteAccessPlatform.Windows.Contracts.csproj" />
<ProjectReference Include="..\RemoteAccessPlatform.Windows.Models\RemoteAccessPlatform.Windows.Models.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>