Initial project snapshot
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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>
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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>
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+14
@@ -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>
|
||||
+125
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
+406
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2066
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
+13
@@ -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; } = [];
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+14
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user