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
+183
View File
@@ -0,0 +1,183 @@
# Windows Access Client
This project is the first native end-user Access Client for the platform. It
sits on top of the proven backend/session/worker foundation and now includes a
real RDP session window with direct worker data-plane support.
## Current scope
- WPF desktop shell in `clients/windows/src/RemoteAccessPlatform.Windows.App`
- application services for auth, organizations, resources, sessions, and gateway attach flows
- secure local token storage via DPAPI for MVP
- organization selection persisted locally
- resource list, active session list, and session window
- direct worker WSS data-plane integration with backend gateway fallback
- binary render receive path for direct worker WSS
- keyboard/mouse input forwarding
- text clipboard actions
- client-to-server file upload actions
- WebSocket state updates and takeover handling
## Solution layout
- `RemoteAccessPlatform.Windows.App`: WPF shell, composition root, windows, views
- `RemoteAccessPlatform.Windows.Application`: view models and client-side orchestration services
- `RemoteAccessPlatform.Windows.Contracts`: service abstractions
- `RemoteAccessPlatform.Windows.Models`: DTOs and persisted settings models
- `RemoteAccessPlatform.Windows.Transport`: HTTP and WebSocket transports
- `RemoteAccessPlatform.Windows.Settings`: DPAPI token storage and local settings persistence
## Build assumptions
- Current repository toolchain is `.NET SDK 10.0.101`
- The solution file is `RemoteAccessPlatform.Windows.slnx`
- WPF target framework is `net10.0-windows`
## Build
```powershell
dotnet build clients/windows/RemoteAccessPlatform.Windows.slnx
```
## Run
```powershell
dotnet run --project clients/windows/src/RemoteAccessPlatform.Windows.App/RemoteAccessPlatform.Windows.App.csproj
```
For a live desktop smoke pass against an already running backend/worker:
```powershell
pwsh -Sta -ExecutionPolicy Bypass -File scripts/windows-smoke/desktop-smoke.ps1
```
For the worker-death/stale-lease verification variant against the current smoke host:
```powershell
pwsh -Sta -ExecutionPolicy Bypass -File scripts/windows-smoke/desktop-smoke.ps1 `
-VerifyWorkerDeath `
-RemoteDockerHost 192.168.200.61 `
-RemoteDockerUser test `
-RemoteDockerPassword <ssh-password>
```
The same worker-death command also covers input-fidelity regression checks. It proves:
- focused session-surface lifecycle in the real WPF window
- keyboard input forwarding through the gateway to the worker
- mouse input forwarding through the gateway to the worker
- attach, detach, reattach, takeover, and worker-death handling without breaking the proven session lifecycle
## Backend endpoints
Default endpoints are defined in [appsettings.json](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\clients\windows\src\RemoteAccessPlatform.Windows.App\appsettings.json).
- API: `http://192.168.200.61:8080/api/v1`
- WebSocket gateway: `ws://192.168.200.61:8080/api/v1/gateway/ws`
- Direct data-plane preference: `prefer_direct_data_plane`, default `true`
- Client environment: `environment`, default `development`; use `production`
or `prod` for production trust behavior
- Direct data-plane connect timeout: `direct_data_plane_connect_timeout_ms`, default `750`
- Direct data-plane color mode: `direct_data_plane_color_mode`, default `full_color`; smoke can set `grayscale` when the worker candidate advertises it
- Direct data-plane platform CA bundle:
`direct_data_plane_platform_ca_bundle`, optional app-local PEM/DER CA bundle
used only for direct worker WSS candidates with `tls_trust_mode=platform_ca`
- Smoke-only direct TLS override: `allow_insecure_direct_data_plane_tls_for_smoke`, default `false`
The project default test backend runs on Docker host `192.168.200.61`
through Docker context `test-ubuntu` / SSH alias `docker-test`.
## MVP behavior
- Login stores access and refresh tokens in DPAPI-protected local storage
- Refresh rotation is handled by the auth service and transport retry path
- Organization selection is restored per user from local settings
- Resource and session actions rely on backend business rules; the client does not reimplement org or session policies
- Session windows connect to the existing gateway using backend-issued attach tokens
- When a session response includes a direct worker WSS data-plane candidate, the client uses it only if the candidate metadata explicitly marks it data-capable (`runtime_transport=json_v1` or `traffic_ready=true`); otherwise it stays on the backend gateway with no user-visible delay
- In production client environment, direct worker WSS is used only when the
candidate is marked `production_trusted=true` or uses `tls_trust_mode` of
`public_ca`/`platform_ca`. Smoke-only or untrusted direct candidates are
skipped and backend gateway fallback is used.
- For `tls_trust_mode=platform_ca`, the client may validate the worker
certificate against `direct_data_plane_platform_ca_bundle` with normal
hostname/SAN validation still enforced. This override is scoped only to the
direct worker WSS connection and is never applied to backend API/gateway
traffic.
- `allow_insecure_direct_data_plane_tls_for_smoke=true` bypasses certificate
validation only in non-production and only for smoke-only direct candidates.
- When direct candidate metadata advertises `render_transport=binary_v1` or `binary_render=true`, the client requests binary direct render frames and feeds them into the same latest-frame presenter path
- When direct candidate metadata advertises `supported_color_modes`, the client requests `direct_data_plane_color_mode`; unsupported modes fall back to `full_color`
- Backend gateway fallback continues to use JSON/base64 render frames for debug/fallback compatibility
- Direct worker WSS attempts are bounded by a short timeout and automatically fall back to the backend gateway
- `session.taken_over` closes the current transport and updates the window status
## Localization Foundation
- User-facing UI strings now live in [Strings.resx](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\clients\windows\src\RemoteAccessPlatform.Windows.Application\Resources\Strings.resx).
- A placeholder [Strings.ru.resx](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\clients\windows\src\RemoteAccessPlatform.Windows.Application\Resources\Strings.ru.resx) exists only to keep the structure ready for future translations.
- XAML uses strongly named static accessors from [Strings.cs](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\clients\windows\src\RemoteAccessPlatform.Windows.Application\Localization\Strings.cs).
- View models resolve backend `message_key` values through the same localization helper and fall back to backend `fallback_message` if a resource key is not available.
- The client should never display raw backend internal text directly as the primary UI contract.
## Structured Messaging
- Backend HTTP errors are parsed as structured envelopes instead of legacy raw strings.
- WebSocket envelopes may now include `event` alongside the existing `type` and `payload`.
- `BackendApiClient` preserves compatibility with legacy `"error": "..."` responses, but prefers the structured form when available.
- `SessionWindowViewModel` resolves websocket event messages by `message_key` first and only then falls back to `fallback_message`.
- Runtime smoke now proves both paths against the live backend: `message_key` resolution for invalid-login errors and fallback rendering for websocket state events whose keys are intentionally not present in client resources.
## Developer Rules
- Add every new user-visible client string to `Strings.resx` first.
- Keep English as the source language during development.
- When introducing a new backend user-facing error or websocket event, define a stable `code` and `message_key`.
- Do not bind UI directly to raw backend English messages when a structured `message_key` is available.
## Known limitations
- RDP rendering is usable but still has small redraw artifacts that must be
hardened in the next RDP visual correctness pass.
- Advanced graphics modes, codecs, tiles, GPU acceleration, fullscreen polish,
audio, printer, webcam, and multi-monitor are not implemented.
- Clipboard is text-only.
- File upload is accepted client-to-server. Server-to-client download has a
runtime-proven core data path and lifecycle blocking through the restricted
`RAP_Transfers\ToClient` drop-zone model; manual Windows-client UI proof is
still pending before full Stage 5.2 acceptance.
- Restricted drive visibility is accepted with the current
`rap-rdp-worker:rdp-p1-region-order2` baseline.
- Linux and mobile clients are not implemented.
- Mesh, node-agent updater runtime, VPN/IP tunnel runtime, and identity sync
runtime are not implemented here.
## Desktop smoke status
- proven against a live backend: login, organization switching, org-scoped resource refresh, and token refresh after forced short access-token expiry
- proven against the live localization-ready messaging model: invalid-login UI error rendering, structured websocket event rendering, fallback websocket state message rendering, takeover event messaging, and worker-death failure messaging
- proven backend/runtime bug fixed during smoke: auth device upsert now casts `trusted_at` and other timestamps explicitly in [backend/internal/modules/auth/postgres_store.go](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\backend\internal\modules\auth\postgres_store.go)
- proven client runtime bugs fixed during smoke:
- empty organization/resource/session views now initialize correctly via code-behind constructors
- dynamic status/session text controls no longer override automation-visible text with static automation names
- proven from the desktop client in Stage 2: focused session-surface lifecycle, keyboard forwarding, and mouse forwarding against the live backend/worker
- proven from the desktop client: `SessionWindow` creation, first render, DataContext binding, gateway connect, attach, detach, reattach, and takeover open path against the live backend
- proven via desktop smoke plus client diagnostics: takeover reuses the same session id and does not create a new desktop session shell for a different remote session
- proven backend fix for desktop failure semantics: [backend/internal/modules/sessiongateway/module.go](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\backend\internal\modules\sessiongateway\module.go) now distinguishes terminal session states from controller takeover instead of treating missing live binding as `session.taken_over`
- proven client hardening for gateway-close fallback: [SessionWindowViewModel.cs](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\clients\windows\src\RemoteAccessPlatform.Windows.Application\ViewModels\SessionWindowViewModel.cs) now refreshes authoritative session state after abrupt gateway closure
- proven end-to-end in the current smoke harness: worker-death desktop handling now reaches the failed session state in the real client UI
- current smoke harness for the remaining path is in [scripts/windows-smoke/desktop-smoke.ps1](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\scripts\windows-smoke\desktop-smoke.ps1)
## Clipboard Text MVP
The session window exposes explicit text-only clipboard actions. The client
does not install global clipboard hooks and does not watch the OS clipboard in
the background. Clipboard text is read or written only when the user invokes
the session-window clipboard buttons inside an active session context.
Blocked clipboard transfers are surfaced through localized session event-log
messages. The client treats backend `message_key` as the localization key and
falls back to the backend English `fallback_message` only when the key is not
present locally.
The client never sends images, HTML, RTF, binary blobs, or file-like clipboard
payloads. Only Unicode text is read from or written to the local clipboard.
@@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/RemoteAccessPlatform.Windows.App/RemoteAccessPlatform.Windows.App.csproj" />
<Project Path="src/RemoteAccessPlatform.Windows.Application/RemoteAccessPlatform.Windows.Application.csproj" />
<Project Path="src/RemoteAccessPlatform.Windows.Contracts/RemoteAccessPlatform.Windows.Contracts.csproj" />
<Project Path="src/RemoteAccessPlatform.Windows.Models/RemoteAccessPlatform.Windows.Models.csproj" />
<Project Path="src/RemoteAccessPlatform.Windows.Settings/RemoteAccessPlatform.Windows.Settings.csproj" />
<Project Path="src/RemoteAccessPlatform.Windows.Transport/RemoteAccessPlatform.Windows.Transport.csproj" />
</Folder>
</Solution>
@@ -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>