Initial project snapshot

This commit is contained in:
2026-04-28 22:29:50 +03:00
commit 8ba0561f4f
365 changed files with 91832 additions and 0 deletions
@@ -0,0 +1,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);
}
}
}