Bring in submodule files.

This commit is contained in:
Jared Goodwin 2024-07-16 09:11:32 -07:00
parent 5c2085fadf
commit 1afcc8c8b5
387 changed files with 64002 additions and 9127 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "submodules/Immense.RemoteControl"]
path = submodules/Immense.RemoteControl
url = git@github.com:immense/RemoteControl.git

View File

@ -39,8 +39,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Desktop.Native\Desktop.Native.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
<ProjectReference Include="..\submodules\Immense.RemoteControl\Immense.RemoteControl.Desktop.Shared\Immense.RemoteControl.Desktop.Shared.csproj" />
</ItemGroup>

View File

@ -1,5 +1,4 @@
using Immense.RemoteControl.Shared.Models;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Remotely.Agent.Interfaces;
using Remotely.Agent.Models;

View File

@ -1,9 +1,9 @@
using Immense.RemoteControl.Shared;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Remotely.Agent.Interfaces;
using Remotely.Shared.Extensions;
using Remotely.Shared.Models;
using Remotely.Shared.Primitives;
using Remotely.Shared.Services;
using Remotely.Shared.Utilities;
using System;

View File

@ -56,8 +56,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Desktop.Shared\Desktop.Shared.csproj" />
<ProjectReference Include="..\submodules\Immense.RemoteControl\Immense.RemoteControl.Desktop.Linux\Immense.RemoteControl.Desktop.Linux.csproj" />
<ProjectReference Include="..\submodules\Immense.RemoteControl\Immense.RemoteControl.Desktop.Shared\Immense.RemoteControl.Desktop.Shared.csproj" />
<ProjectReference Include="..\submodules\Immense.RemoteControl\Immense.RemoteControl.Desktop.UI\Immense.RemoteControl.Desktop.UI.csproj" />
<ProjectReference Include="..\Desktop.UI\Desktop.UI.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,150 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Desktop.UI.Services;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
namespace Immense.RemoteControl.Desktop.Linux.Services;
internal class AppStartup : IAppStartup
{
private readonly IAppState _appState;
private readonly IKeyboardMouseInput _inputService;
private readonly IDesktopHubConnection _desktopHub;
private readonly IClipboardService _clipboardService;
private readonly IChatHostService _chatHostService;
private readonly ICursorIconWatcher _cursorIconWatcher;
private readonly IUiDispatcher _dispatcher;
private readonly IIdleTimer _idleTimer;
private readonly IShutdownService _shutdownService;
private readonly IBrandingProvider _brandingProvider;
private readonly ILogger<AppStartup> _logger;
public AppStartup(
IAppState appState,
IKeyboardMouseInput inputService,
IDesktopHubConnection desktopHub,
IClipboardService clipboardService,
IChatHostService chatHostService,
ICursorIconWatcher iconWatcher,
IUiDispatcher dispatcher,
IIdleTimer idleTimer,
IShutdownService shutdownService,
IBrandingProvider brandingProvider,
ILogger<AppStartup> logger)
{
_appState = appState;
_inputService = inputService;
_desktopHub = desktopHub;
_clipboardService = clipboardService;
_chatHostService = chatHostService;
_cursorIconWatcher = iconWatcher;
_dispatcher = dispatcher;
_idleTimer = idleTimer;
_shutdownService = shutdownService;
_brandingProvider = brandingProvider;
_logger = logger;
}
public async Task Run()
{
await _brandingProvider.Initialize();
if (_appState.Mode is AppMode.Unattended or AppMode.Attended)
{
_clipboardService.BeginWatching();
_inputService.Init();
_cursorIconWatcher.OnChange += CursorIconWatcher_OnChange;
}
switch (_appState.Mode)
{
case AppMode.Unattended:
{
var result = await _dispatcher.StartHeadless();
if (!result.IsSuccess)
{
return;
}
await StartScreenCasting().ConfigureAwait(false);
break;
}
case AppMode.Attended:
{
_dispatcher.StartClassicDesktop();
break;
}
case AppMode.Chat:
{
var result = await _dispatcher.StartHeadless();
if (!result.IsSuccess)
{
return;
}
await _chatHostService
.StartChat(_appState.PipeName, _appState.OrganizationName)
.ConfigureAwait(false);
break;
}
default:
break;
}
}
private async Task StartScreenCasting()
{
if (!await _desktopHub.Connect(TimeSpan.FromSeconds(30), _dispatcher.ApplicationExitingToken))
{
await _shutdownService.Shutdown();
return;
}
var result = await _desktopHub.SendUnattendedSessionInfo(
_appState.SessionId,
_appState.AccessKey,
Environment.MachineName,
_appState.RequesterName,
_appState.OrganizationName);
if (!result.IsSuccess)
{
_logger.LogError(result.Exception, "An error occurred while trying to establish a session with the server.");
await _shutdownService.Shutdown();
return;
}
try
{
if (_appState.ArgDict.ContainsKey("relaunch"))
{
_logger.LogInformation("Resuming after relaunch.");
var viewerIDs = _appState.RelaunchViewers;
await _desktopHub.NotifyViewersRelaunchedScreenCasterReady(viewerIDs);
}
else
{
await _desktopHub.NotifyRequesterUnattendedReady();
}
}
finally
{
_idleTimer.Start();
}
}
private async void CursorIconWatcher_OnChange(object? sender, CursorInfo cursor)
{
if (_appState.Viewers.Any() == true &&
_desktopHub.IsConnected)
{
foreach (var viewer in _appState.Viewers.Values)
{
await viewer.SendCursorChange(cursor);
}
}
}
}

View File

@ -0,0 +1,15 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class AudioCapturerLinux : IAudioCapturer
{
#pragma warning disable CS0067
public event EventHandler<byte[]>? AudioSampleReady;
#pragma warning restore
public void ToggleAudio(bool toggleOn)
{
// Not implemented.
}
}

View File

@ -0,0 +1,15 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Shared.Models;
using System.Drawing;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class CursorIconWatcherLinux : ICursorIconWatcher
{
#pragma warning disable CS0067
public event EventHandler<CursorInfo>? OnChange;
#pragma warning restore
public CursorInfo GetCurrentCursor() => new(Array.Empty<byte>(), Point.Empty, "default");
}

View File

@ -0,0 +1,160 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Desktop.Shared.ViewModels;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
using Immense.RemoteControl.Desktop.UI.Views;
using Immense.RemoteControl.Desktop.UI.Services;
using System.Threading;
using System.IO;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class FileTransferServiceLinux : IFileTransferService
{
private static readonly ConcurrentDictionary<string, FileTransferWindow> _fileTransferWindows = new();
private static readonly ConcurrentDictionary<string, FileStream> _partialTransfers = new();
private static readonly SemaphoreSlim _writeLock = new(1, 1);
private static volatile bool _messageBoxPending;
private readonly IViewModelFactory _viewModelFactory;
private readonly IUiDispatcher _dispatcher;
private readonly IDialogProvider _dialogProvider;
private readonly ILogger<FileTransferServiceLinux> _logger;
public FileTransferServiceLinux(
IViewModelFactory viewModelFactory,
IUiDispatcher dispatcher,
IDialogProvider dialogProvider,
ILogger<FileTransferServiceLinux> logger)
{
_viewModelFactory = viewModelFactory;
_dispatcher = dispatcher;
_dialogProvider = dialogProvider;
_logger = logger;
}
public string GetBaseDirectory()
{
var desktopDir = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (Directory.Exists(desktopDir))
{
return desktopDir;
}
return Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "RemoteControl")).FullName;
}
public void OpenFileTransferWindow(IViewer viewer)
{
_dispatcher.Post(() =>
{
if (_fileTransferWindows.TryGetValue(viewer.ViewerConnectionId, out var window))
{
window.Activate();
}
else
{
window = new FileTransferWindow
{
DataContext = _viewModelFactory.CreateFileTransferWindowViewModel(viewer)
};
window.Closed += (sender, arg) =>
{
_fileTransferWindows.Remove(viewer.ViewerConnectionId, out _);
};
_fileTransferWindows.AddOrUpdate(viewer.ViewerConnectionId, window, (k, v) => window);
window.Show();
}
});
}
public async Task ReceiveFile(byte[] buffer, string fileName, string messageId, bool endOfFile, bool startOfFile)
{
try
{
await _writeLock.WaitAsync();
var baseDir = GetBaseDirectory();
if (startOfFile)
{
var filePath = Path.Combine(baseDir, fileName);
if (File.Exists(filePath))
{
var count = 0;
var ext = Path.GetExtension(fileName);
var fileWithoutExt = Path.GetFileNameWithoutExtension(fileName);
while (File.Exists(filePath))
{
filePath = Path.Combine(baseDir, $"{fileWithoutExt}-{count}{ext}");
count++;
}
}
File.Create(filePath).Close();
var fs = new FileStream(filePath, FileMode.OpenOrCreate);
_partialTransfers.AddOrUpdate(messageId, fs, (k, v) => fs);
}
var fileStream = _partialTransfers[messageId];
if (buffer?.Length > 0)
{
await fileStream.WriteAsync(buffer);
}
if (endOfFile)
{
fileStream.Close();
_partialTransfers.Remove(messageId, out _);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while receiving file.");
}
finally
{
_writeLock.Release();
if (endOfFile)
{
await Task.Run(ShowTransferComplete);
}
}
}
public async Task UploadFile(
FileUpload fileUpload,
IViewer viewer,
Action<double> progressUpdateCallback,
CancellationToken cancelToken)
{
try
{
await viewer.SendFile(fileUpload, progressUpdateCallback, cancelToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while uploading file.");
}
}
private async Task ShowTransferComplete()
{
// Prevent multiple dialogs from popping up.
if (!_messageBoxPending)
{
_messageBoxPending = true;
await _dialogProvider.Show($"File tranfer complete. Files saved to directory:\n\n{GetBaseDirectory()}",
"Tranfer Complete",
MessageBoxType.OK);
_messageBoxPending = false;
}
}
}

View File

@ -0,0 +1,259 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Native.Linux;
using Immense.RemoteControl.Desktop.Shared.Services;
using Microsoft.Extensions.Logging;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class KeyboardMouseInputLinux : IKeyboardMouseInput
{
private readonly ILogger<KeyboardMouseInputLinux> _logger;
private nint Display { get; set; }
public KeyboardMouseInputLinux(ILogger<KeyboardMouseInputLinux> logger)
{
_logger = logger;
}
public void Init()
{
// Nothing to do here. The Windows implementation needs to start
// a processing queue to keep all input simulation on the same
// thread. Linux doesn't.
}
public void SendKeyDown(string key)
{
try
{
InitDisplay();
key = ConvertJavaScriptKeyToX11Key(key);
var keySim = LibX11.XStringToKeysym(key);
if (keySim == nint.Zero)
{
_logger.LogError("Key not mapped: {key}", key);
return;
}
var keyCode = LibX11.XKeysymToKeycode(Display, keySim);
LibXtst.XTestFakeKeyEvent(Display, keyCode, true, 0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending key down.");
}
}
public void SendKeyUp(string key)
{
try
{
InitDisplay();
key = ConvertJavaScriptKeyToX11Key(key);
var keySim = LibX11.XStringToKeysym(key);
if (keySim == nint.Zero)
{
_logger.LogError("Key not mapped: {key}", key);
return;
}
var keyCode = LibX11.XKeysymToKeycode(Display, keySim);
LibXtst.XTestFakeKeyEvent(Display, keyCode, false, 0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending key up.");
}
}
public void SendMouseButtonAction(int button, ButtonAction buttonAction, double percentX, double percentY, IViewer viewer)
{
try
{
var isPressed = buttonAction == ButtonAction.Down;
// Browser buttons start at 0. XTest starts at 1.
var mouseButton = (uint)(button + 1);
InitDisplay();
SendMouseMove(percentX, percentY, viewer);
LibXtst.XTestFakeButtonEvent(Display, mouseButton, isPressed, 0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending mouse button action.");
}
}
public void SendMouseMove(double percentX, double percentY, IViewer viewer)
{
try
{
InitDisplay();
var screenBounds = viewer.Capturer.CurrentScreenBounds;
LibXtst.XTestFakeMotionEvent(Display,
LibX11.XDefaultScreen(Display),
screenBounds.X + (int)(screenBounds.Width * percentX),
screenBounds.Y + (int)(screenBounds.Height * percentY),
0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending mouse move.");
}
}
public void SendMouseWheel(int deltaY)
{
try
{
InitDisplay();
if (deltaY > 0)
{
LibXtst.XTestFakeButtonEvent(Display, 4, true, 0);
LibXtst.XTestFakeButtonEvent(Display, 4, false, 0);
}
else
{
LibXtst.XTestFakeButtonEvent(Display, 5, true, 0);
LibXtst.XTestFakeButtonEvent(Display, 5, false, 0);
}
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending mouse wheel.");
}
}
public void SendRightMouseDown(double percentX, double percentY, IViewer viewer)
{
try
{
InitDisplay();
SendMouseMove(percentX, percentY, viewer);
LibXtst.XTestFakeButtonEvent(Display, 3, true, 0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending mouse right down.");
}
}
public void SendRightMouseUp(double percentX, double percentY, IViewer viewer)
{
try
{
InitDisplay();
SendMouseMove(percentX, percentY, viewer);
LibXtst.XTestFakeButtonEvent(Display, 3, false, 0);
LibX11.XSync(Display, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending mouse right up.");
}
}
public void SendText(string transferText)
{
foreach (var key in transferText)
{
SendKeyDown(key.ToString());
SendKeyUp(key.ToString());
}
}
public void SetKeyStatesUp()
{
// Not implemented.
}
public void ToggleBlockInput(bool toggleOn)
{
// Not implemented.
}
private string ConvertJavaScriptKeyToX11Key(string key)
{
var keySym = key switch
{
"ArrowDown" => "Down",
"ArrowUp" => "Up",
"ArrowLeft" => "Left",
"ArrowRight" => "Right",
"Enter" => "Return",
"Esc" => "Escape",
"Alt" => "Alt_L",
"Control" => "Control_L",
"Shift" => "Shift_L",
"PAUSE" => "Pause",
"BREAK" => "Break",
"Backspace" => "BackSpace",
"Tab" => "Tab",
"CapsLock" => "Caps_Lock",
"Delete" => "Delete",
"PageUp" => "Page_Up",
"PageDown" => "Page_Down",
"NumLock" => "Num_Lock",
"ScrollLock" => "Scroll_Lock",
"ContextMenu" => "Menu",
" " => "space",
"!" => "exclam",
"\"" => "quotedbl",
"#" => "numbersign",
"$" => "dollar",
"%" => "percent",
"&" => "ampersand",
"'" => "apostrophe",
"(" => "parenleft",
")" => "parenright",
"*" => "asterisk",
"+" => "plus",
"," => "comma",
"-" => "minus",
"." => "period",
"/" => "slash",
":" => "colon",
";" => "semicolon",
"<" => "less",
"=" => "equal",
">" => "greater",
"?" => "question",
"@" => "at",
"[" => "bracketleft",
"\\" => "backslash",
"]" => "bracketright",
"_" => "underscore",
"`" => "grave",
"{" => "braceleft",
"|" => "bar",
"}" => "braceright",
"~" => "asciitilde",
_ => key,
};
return keySym;
}
private void InitDisplay()
{
try
{
if (Display == nint.Zero)
{
Display = LibX11.XOpenDisplay(string.Empty);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while initializing display.");
}
}
}

View File

@ -0,0 +1,239 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Native.Linux;
using Immense.RemoteControl.Desktop.Shared.Services;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Primitives;
using SkiaSharp;
using System.Drawing;
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class ScreenCapturerLinux : IScreenCapturer
{
private readonly IImageHelper _imageHelper;
private readonly ILogger<ScreenCapturerLinux> _logger;
private readonly object _screenBoundsLock = new();
private readonly Dictionary<string, LibXrandr.XRRMonitorInfo> _x11Screens = new();
private SKBitmap? _currentFrame;
private SKBitmap? _previousFrame;
public ScreenCapturerLinux(
IImageHelper imageHelper,
ILogger<ScreenCapturerLinux> logger)
{
_imageHelper = imageHelper;
_logger = logger;
Display = LibX11.XOpenDisplay(string.Empty);
Init();
}
public event EventHandler<Rectangle>? ScreenChanged;
public bool CaptureFullscreen { get; set; } = true;
public Rectangle CurrentScreenBounds { get; private set; }
public nint Display { get; private set; }
public bool IsGpuAccelerated => false;
public string SelectedScreen { get; private set; } = string.Empty;
public void Dispose()
{
LibX11.XCloseDisplay(Display);
GC.SuppressFinalize(this);
}
public IEnumerable<string> GetDisplayNames()
{
return _x11Screens.Keys.Select(x => x.ToString());
}
public SKRect GetFrameDiffArea()
{
if (_currentFrame is null)
{
return SKRect.Empty;
}
return _imageHelper.GetDiffArea(_currentFrame, _previousFrame, CaptureFullscreen);
}
public Result<SKBitmap> GetImageDiff()
{
if (_currentFrame is null)
{
return Result.Fail<SKBitmap>("Current frame is null.");
}
return _imageHelper.GetImageDiff(_currentFrame, _previousFrame);
}
public Result<SKBitmap> GetNextFrame()
{
lock (_screenBoundsLock)
{
try
{
if (_currentFrame != null)
{
_previousFrame?.Dispose();
_previousFrame = _currentFrame;
}
_currentFrame = GetX11Capture();
return Result.Ok(_currentFrame);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while getting next frame.");
Init();
return Result.Fail<SKBitmap>(ex);
}
}
}
public int GetScreenCount()
{
return _x11Screens.Count;
}
public Rectangle GetVirtualScreenBounds()
{
var lowestX = 0;
var highestX = 0;
var lowestY = 0;
var highestY = 0;
foreach (var screen in _x11Screens)
{
lowestX = Math.Min(lowestX, screen.Value.x);
highestX = Math.Max(highestX, screen.Value.x + screen.Value.width);
lowestY = Math.Min(lowestY, screen.Value.y);
highestY = Math.Max(highestY, screen.Value.y + screen.Value.height);
}
return new Rectangle(lowestX, lowestY, highestX - lowestX, highestY - lowestY);
}
public void Init()
{
try
{
CaptureFullscreen = true;
_x11Screens.Clear();
var monitorsPtr = LibXrandr.XRRGetMonitors(Display, LibX11.XDefaultRootWindow(Display), true, out var monitorCount);
var monitorInfoSize = Marshal.SizeOf<LibXrandr.XRRMonitorInfo>();
for (var i = 0; i < monitorCount; i++)
{
var monitorPtr = new nint(monitorsPtr.ToInt64() + i * monitorInfoSize);
var monitorInfo = Marshal.PtrToStructure<LibXrandr.XRRMonitorInfo>(monitorPtr);
_logger.LogInformation($"Found monitor: " +
$"{monitorInfo.width}," +
$"{monitorInfo.height}," +
$"{monitorInfo.x}, " +
$"{monitorInfo.y}");
_x11Screens.Add(i.ToString(), monitorInfo);
}
LibXrandr.XRRFreeMonitors(monitorsPtr);
if (string.IsNullOrWhiteSpace(SelectedScreen) ||
!_x11Screens.ContainsKey(SelectedScreen))
{
SelectedScreen = _x11Screens.Keys.First();
RefreshCurrentScreenBounds();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while initializing.");
}
}
public void SetSelectedScreen(string displayName)
{
lock (_screenBoundsLock)
{
try
{
_logger.LogInformation("Setting display to {displayName}.", displayName);
if (displayName == SelectedScreen)
{
return;
}
if (_x11Screens.ContainsKey(displayName))
{
SelectedScreen = displayName;
}
else
{
SelectedScreen = _x11Screens.Keys.First();
}
RefreshCurrentScreenBounds();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while setting selected display.");
}
}
}
private SKBitmap GetX11Capture()
{
var currentFrame = new SKBitmap(CurrentScreenBounds.Width, CurrentScreenBounds.Height);
var window = LibX11.XDefaultRootWindow(Display);
var imagePointer = LibX11.XGetImage(Display,
window,
CurrentScreenBounds.X,
CurrentScreenBounds.Y,
CurrentScreenBounds.Width,
CurrentScreenBounds.Height,
~0,
2);
if (imagePointer == nint.Zero)
{
return currentFrame;
}
var image = Marshal.PtrToStructure<LibX11.XImage>(imagePointer);
var pixels = currentFrame.GetPixels();
unsafe
{
var scan1 = (byte*)pixels.ToPointer();
var scan2 = (byte*)image.data.ToPointer();
var bytesPerPixel = currentFrame.BytesPerPixel;
var totalSize = currentFrame.Height * currentFrame.Width * bytesPerPixel;
for (var counter = 0; counter < totalSize - bytesPerPixel; counter++)
{
scan1[counter] = scan2[counter];
}
}
Marshal.DestroyStructure<LibX11.XImage>(imagePointer);
LibX11.XDestroyImage(imagePointer);
return currentFrame;
}
private void RefreshCurrentScreenBounds()
{
var screen = _x11Screens[SelectedScreen];
_logger.LogInformation($"Setting new screen bounds: " +
$"{screen.width}," +
$"{screen.height}," +
$"{screen.x}, " +
$"{screen.y}");
CurrentScreenBounds = new Rectangle(screen.x, screen.y, screen.width, screen.height);
CaptureFullscreen = true;
ScreenChanged?.Invoke(this, CurrentScreenBounds);
}
}

View File

@ -0,0 +1,49 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Services;
using Microsoft.Extensions.Logging;
using Immense.RemoteControl.Desktop.UI.Services;
namespace Immense.RemoteControl.Desktop.Linux.Services;
public class ShutdownServiceLinux : IShutdownService
{
private readonly IDesktopHubConnection _hubConnection;
private readonly IUiDispatcher _dispatcher;
private readonly IAppState _appState;
private readonly ILogger<ShutdownServiceLinux> _logger;
public ShutdownServiceLinux(
IDesktopHubConnection hubConnection,
IUiDispatcher dispatcher,
IAppState appState,
ILogger<ShutdownServiceLinux> logger)
{
_hubConnection = hubConnection;
_dispatcher = dispatcher;
_appState = appState;
_logger = logger;
}
public async Task Shutdown()
{
_logger.LogDebug("Exiting process ID {processId}.", Environment.ProcessId);
await TryDisconnectViewers();
_dispatcher.Shutdown();
}
private async Task TryDisconnectViewers()
{
try
{
if (_hubConnection.IsConnected && _appState.Viewers.Any())
{
await _hubConnection.DisconnectAllViewers();
await _hubConnection.Disconnect();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending shutdown notice to viewers.");
}
}
}

View File

@ -0,0 +1,37 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Startup;
using Microsoft.Extensions.DependencyInjection;
using Immense.RemoteControl.Desktop.Linux.Services;
using Immense.RemoteControl.Desktop.UI.ViewModels;
using Immense.RemoteControl.Desktop.UI.Services;
using Immense.RemoteControl.Desktop.UI.Startup;
namespace Immense.RemoteControl.Desktop.Linux.Startup;
public static class IServiceCollectionExtensions
{
/// <summary>
/// Adds Linux and cross-platform remote control services to the service collection.
/// All methods on <see cref="IRemoteControlClientBuilder"/> must be called to register
/// required services.
/// </summary>
/// <param name="services"></param>
/// <param name="clientConfig"></param>
public static void AddRemoteControlLinux(
this IServiceCollection services,
Action<IRemoteControlClientBuilder> clientConfig)
{
services.AddRemoteControlXplat(clientConfig);
services.AddRemoteControlUi();
services.AddSingleton<IAppStartup, AppStartup>();
services.AddSingleton<ICursorIconWatcher, CursorIconWatcherLinux>();
services.AddSingleton<IKeyboardMouseInput, KeyboardMouseInputLinux>();
services.AddSingleton<IClipboardService, ClipboardService>();
services.AddSingleton<IAudioCapturer, AudioCapturerLinux>();
services.AddTransient<IScreenCapturer, ScreenCapturerLinux>();
services.AddScoped<IFileTransferService, FileTransferServiceLinux>();
services.AddSingleton<ISessionIndicator, SessionIndicator>();
services.AddSingleton<IShutdownService, ShutdownServiceLinux>();
}
}

5
Desktop.Linux/Usings.cs Normal file
View File

@ -0,0 +1,5 @@
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Text;
global using System.Threading.Tasks;

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,140 @@
/*
Copyright 1985, 1986, 1987, 1991, 1998 The Open Group
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
the above copyright notice appear in all copies and that both that
copyright notice and this permission notice appear in supporting
documentation.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name of The Open Group shall not be
used in advertising or otherwise to promote the sale, use or other dealings
in this Software without prior written authorization from The Open Group.
*/
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Linux;
public static unsafe class LibX11
{
[DllImport("libX11")]
public static extern nint XGetImage(nint display, nint drawable, int x, int y, int width, int height, long plane_mask, int format);
[DllImport("libX11")]
public static extern nint XDefaultVisual(nint display, int screen_number);
[DllImport("libX11")]
public static extern int XScreenCount(nint display);
[DllImport("libX11")]
public static extern int XDefaultScreen(nint display);
[DllImport("libX11")]
public static extern nint XOpenDisplay(string display_name);
[DllImport("libX11")]
public static extern void XCloseDisplay(nint display);
[DllImport("libX11")]
public static extern nint XRootWindow(nint display, int screen_number);
[DllImport("libX11")]
public static extern nint XGetSubImage(nint display, nint drawable, int x, int y, uint width, uint height, ulong plane_mask, int format, nint dest_image, int dest_x, int dest_y);
[DllImport("libX11")]
public static extern nint XScreenOfDisplay(nint display, int screen_number);
[DllImport("libX11")]
public static extern int XDisplayWidth(nint display, int screen_number);
[DllImport("libX11")]
public static extern int XDisplayHeight(nint display, int screen_number);
[DllImport("libX11")]
public static extern int XWidthOfScreen(nint screen);
[DllImport("libX11")]
public static extern int XHeightOfScreen(nint screen);
[DllImport("libX11")]
public static extern nint XDefaultGC(nint display, int screen_number);
[DllImport("libX11")]
public static extern nint XDefaultRootWindow(nint display);
[DllImport("libX11")]
public static extern void XGetInputFocus(nint display, out nint focus_return, out int revert_to_return);
[DllImport("libX11")]
public static extern nint XStringToKeysym(string key);
[DllImport("libX11")]
public static extern uint XKeysymToKeycode(nint display, nint keysym);
[DllImport("libX11")]
public static extern nint XRootWindowOfScreen(nint screen);
[DllImport("libX11")]
public static extern ulong XNextRequest(nint display);
[DllImport("libX11")]
public static extern void XForceScreenSaver(nint display, int mode);
[DllImport("libX11")]
public static extern void XSync(nint display, bool discard);
[DllImport("libX11")]
public static extern void XDestroyImage(nint ximage);
[DllImport("libX11")]
public static extern void XNoOp(nint display);
[DllImport("libX11")]
public static extern void XFree(nint data);
[DllImport("libX11")]
public static extern int XGetWindowAttributes(nint display, nint window, out XWindowAttributes windowAttributes);
public struct XImage
{
public int width;
public int height; /* size of image */
public int xoffset; /* number of pixels offset in X direction */
public int format; /* XYBitmap, XYPixmap, ZPixmap */
//public char* data; /* pointer to image data */
public nint data; /* pointer to image data */
public int byte_order; /* data byte order, LSBFirst, MSBFirst */
public int bitmap_unit; /* quant. of scanline 8, 16, 32 */
public int bitmap_bit_order; /* LSBFirst, MSBFirst */
public int bitmap_pad; /* 8, 16, 32 either XY or ZPixmap */
public int depth; /* depth of image */
public int bytes_per_line; /* accelerator to next scanline */
public int bits_per_pixel; /* bits per pixel (ZPixmap) */
public ulong red_mask; /* bits in z arrangement */
public ulong green_mask;
public ulong blue_mask;
public nint obdata; /* hook for the object routines to hang on */
}
public struct XWindowAttributes
{
public int x;
public int y;
public int width;
public int height;
public int border_width;
public int depth;
public nint visual;
public nint root;
public int @class;
public int bit_gravity;
public int win_gravity;
public int backing_store;
public ulong backing_planes;
public ulong backing_pixel;
public bool save_under;
public nint colormap;
public bool map_installed;
public int map_state;
public long all_event_masks;
public long your_event_mask;
public long do_not_propagate_mask;
public bool override_redirect;
public nint screen;
}
}

View File

@ -0,0 +1,15 @@
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Linux;
public class LibXtst
{
[DllImport("libXtst")]
public static extern bool XTestQueryExtension(nint display, out int event_base, out int error_base, out int major_version, out int minor_version);
[DllImport("libXtst")]
public static extern void XTestFakeKeyEvent(nint display, uint keycode, bool is_press, ulong delay);
[DllImport("libXtst")]
public static extern void XTestFakeButtonEvent(nint display, uint button, bool is_press, ulong delay);
[DllImport("libXtst")]
public static extern void XTestFakeMotionEvent(nint display, int screen_number, int x, int y, ulong delay);
}

View File

@ -0,0 +1,9 @@
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Linux;
public class Libc
{
[DllImport("libc", SetLastError = true)]
public static extern uint geteuid();
}

View File

@ -0,0 +1,62 @@
/*
* Copyright © 2000 Compaq Computer Corporation, Inc.
* Copyright © 2002 Hewlett-Packard Company, Inc.
* Copyright © 2006 Intel Corporation
* Copyright © 2008 Red Hat, Inc.
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that copyright
* notice and this permission notice appear in supporting documentation, and
* that the name of the copyright holders not be used in advertising or
* publicity pertaining to distribution of the software without specific,
* written prior permission. The copyright holders make no representations
* about the suitability of this software for any purpose. It is provided "as
* is" without express or implied warranty.
*
* THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
* INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO
* EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY SPECIAL, INDIRECT OR
* CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
* DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
* TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
* OF THIS SOFTWARE.
*
* Author: Jim Gettys, HP Labs, Hewlett-Packard, Inc.
* Keith Packard, Intel Corporation
*/
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Linux;
public static class LibXrandr
{
[StructLayout(LayoutKind.Sequential)]
public struct XRRMonitorInfo
{
// Atom
public nint name;
public bool primary;
public bool automatic;
public int noutput;
public int x;
public int y;
public int width;
public int height;
public int mwidth;
public int mheight;
// RROutput*
public nint outputs;
}
[DllImport("libXrandr")]
public static extern nint XRRGetMonitors(nint display, nint window, bool get_active, out int monitors);
[DllImport("libXrandr")]
public static extern void XRRFreeMonitors(nint monitors);
[DllImport("libXrandr")]
public static extern nint XRRAllocateMonitor(nint display, int output);
}

View File

@ -0,0 +1,365 @@
using System;
using System.Runtime.InteropServices;
using System.Security;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
public static class ADVAPI32
{
#region Structs
public struct TOKEN_PRIVILEGES
{
public struct LUID
{
public uint LowPart;
public int HighPart;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct LUID_AND_ATTRIBUTES
{
public LUID Luid;
public uint Attributes;
}
public int PrivilegeCount;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = ANYSIZE_ARRAY)]
public LUID_AND_ATTRIBUTES[] Privileges;
}
public class USEROBJECTFLAGS
{
public int fInherit = 0;
public int fReserved = 0;
public int dwFlags = 0;
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int Length;
public nint lpSecurityDescriptor;
public bool bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public nint hProcess;
public nint hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public nint lpReserved2;
public nint hStdInput;
public nint hStdOutput;
public nint hStdError;
}
#endregion
#region Enums
public enum TOKEN_INFORMATION_CLASS
{
/// <summary>
    /// The buffer receives a TOKEN_USER structure that contains the user account of the token.
    /// </summary>
TokenUser = 1,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS structure that contains the group accounts associated with the token.
    /// </summary>
TokenGroups,
/// <summary>
    /// The buffer receives a TOKEN_PRIVILEGES structure that contains the privileges of the token.
    /// </summary>
TokenPrivileges,
/// <summary>
    /// The buffer receives a TOKEN_OWNER structure that contains the default owner security identifier (SID) for newly created objects.
    /// </summary>
TokenOwner,
/// <summary>
    /// The buffer receives a TOKEN_PRIMARY_GROUP structure that contains the default primary group SID for newly created objects.
    /// </summary>
TokenPrimaryGroup,
/// <summary>
    /// The buffer receives a TOKEN_DEFAULT_DACL structure that contains the default DACL for newly created objects.
    /// </summary>
TokenDefaultDacl,
/// <summary>
    /// The buffer receives a TOKEN_SOURCE structure that contains the source of the token. TOKEN_QUERY_SOURCE access is needed to retrieve this information.
    /// </summary>
TokenSource,
/// <summary>
    /// The buffer receives a TOKEN_TYPE value that indicates whether the token is a primary or impersonation token.
    /// </summary>
TokenType,
/// <summary>
    /// The buffer receives a SECURITY_IMPERSONATION_LEVEL value that indicates the impersonation level of the token. If the access token is not an impersonation token, the function fails.
    /// </summary>
TokenImpersonationLevel,
/// <summary>
    /// The buffer receives a TOKEN_STATISTICS structure that contains various token statistics.
    /// </summary>
TokenStatistics,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS structure that contains the list of restricting SIDs in a restricted token.
    /// </summary>
TokenRestrictedSids,
/// <summary>
    /// The buffer receives a DWORD value that indicates the Terminal Services session identifier that is associated with the token.
    /// </summary>
TokenSessionId,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS_AND_PRIVILEGES structure that contains the user SID, the group accounts, the restricted SIDs, and the authentication ID associated with the token.
    /// </summary>
TokenGroupsAndPrivileges,
/// <summary>
    /// Reserved.
    /// </summary>
TokenSessionReference,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token includes the SANDBOX_INERT flag.
    /// </summary>
TokenSandBoxInert,
/// <summary>
    /// Reserved.
    /// </summary>
TokenAuditPolicy,
/// <summary>
    /// The buffer receives a TOKEN_ORIGIN value.
    /// </summary>
TokenOrigin,
/// <summary>
    /// The buffer receives a TOKEN_ELEVATION_TYPE value that specifies the elevation level of the token.
    /// </summary>
TokenElevationType,
/// <summary>
    /// The buffer receives a TOKEN_LINKED_TOKEN structure that contains a handle to another token that is linked to this token.
    /// </summary>
TokenLinkedToken,
/// <summary>
    /// The buffer receives a TOKEN_ELEVATION structure that specifies whether the token is elevated.
    /// </summary>
TokenElevation,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token has ever been filtered.
    /// </summary>
TokenHasRestrictions,
/// <summary>
    /// The buffer receives a TOKEN_ACCESS_INFORMATION structure that specifies security information contained in the token.
    /// </summary>
TokenAccessInformation,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if virtualization is allowed for the token.
    /// </summary>
TokenVirtualizationAllowed,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if virtualization is enabled for the token.
    /// </summary>
TokenVirtualizationEnabled,
/// <summary>
    /// The buffer receives a TOKEN_MANDATORY_LABEL structure that specifies the token's integrity level.
    /// </summary>
TokenIntegrityLevel,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token has the UIAccess flag set.
    /// </summary>
TokenUIAccess,
/// <summary>
    /// The buffer receives a TOKEN_MANDATORY_POLICY structure that specifies the token's mandatory integrity policy.
    /// </summary>
TokenMandatoryPolicy,
/// <summary>
    /// The buffer receives the token's logon security identifier (SID).
    /// </summary>
TokenLogonSid,
/// <summary>
    /// The maximum value for this enumeration
    /// </summary>
MaxTokenInfoClass
}
public enum LOGON_TYPE
{
LOGON32_LOGON_INTERACTIVE = 2,
LOGON32_LOGON_NETWORK,
LOGON32_LOGON_BATCH,
LOGON32_LOGON_SERVICE,
LOGON32_LOGON_UNLOCK = 7,
LOGON32_LOGON_NETWORK_CLEARTEXT,
LOGON32_LOGON_NEW_CREDENTIALS
}
public enum LOGON_PROVIDER
{
LOGON32_PROVIDER_DEFAULT,
LOGON32_PROVIDER_WINNT35,
LOGON32_PROVIDER_WINNT40,
LOGON32_PROVIDER_WINNT50
}
[Flags]
public enum CreateProcessFlags
{
CREATE_BREAKAWAY_FROM_JOB = 0x01000000,
CREATE_DEFAULT_ERROR_MODE = 0x04000000,
CREATE_NEW_CONSOLE = 0x00000010,
CREATE_NEW_PROCESS_GROUP = 0x00000200,
CREATE_NO_WINDOW = 0x08000000,
CREATE_PROTECTED_PROCESS = 0x00040000,
CREATE_PRESERVE_CODE_AUTHZ_LEVEL = 0x02000000,
CREATE_SEPARATE_WOW_VDM = 0x00000800,
CREATE_SHARED_WOW_VDM = 0x00001000,
CREATE_SUSPENDED = 0x00000004,
CREATE_UNICODE_ENVIRONMENT = 0x00000400,
DEBUG_ONLY_THIS_PROCESS = 0x00000002,
DEBUG_PROCESS = 0x00000001,
DETACHED_PROCESS = 0x00000008,
EXTENDED_STARTUPINFO_PRESENT = 0x00080000,
INHERIT_PARENT_AFFINITY = 0x00010000
}
public enum TOKEN_TYPE : int
{
TokenPrimary = 1,
TokenImpersonation = 2
}
public enum SECURITY_IMPERSONATION_LEVEL : int
{
SecurityAnonymous = 0,
SecurityIdentification = 1,
SecurityImpersonation = 2,
SecurityDelegation = 3,
}
#endregion
#region Constants
public const int TOKEN_DUPLICATE = 0x0002;
public const uint MAXIMUM_ALLOWED = 0x2000000;
public const int CREATE_NEW_CONSOLE = 0x00000010;
public const int CREATE_NO_WINDOW = 0x08000000;
public const int CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public const int STARTF_USESHOWWINDOW = 0x00000001;
public const int DETACHED_PROCESS = 0x00000008;
public const int TOKEN_ALL_ACCESS = 0x000f01ff;
public const int PROCESS_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0xFFF;
public const int STANDARD_RIGHTS_REQUIRED = 0x000F0000;
public const int SYNCHRONIZE = 0x00100000;
public const int IDLE_PRIORITY_CLASS = 0x40;
public const int NORMAL_PRIORITY_CLASS = 0x20;
public const int HIGH_PRIORITY_CLASS = 0x80;
public const int REALTIME_PRIORITY_CLASS = 0x100;
public const uint SE_PRIVILEGE_ENABLED_BY_DEFAULT = 0x00000001;
public const uint SE_PRIVILEGE_ENABLED = 0x00000002;
public const uint SE_PRIVILEGE_REMOVED = 0x00000004;
public const uint SE_PRIVILEGE_USED_FOR_ACCESS = 0x80000000;
public const int ANYSIZE_ARRAY = 1;
public const int UOI_FLAGS = 1;
public const int UOI_NAME = 2;
public const int UOI_TYPE = 3;
public const int UOI_USER_SID = 4;
public const int UOI_HEAPSIZE = 5;
public const int UOI_IO = 6;
#endregion
#region DLL Imports
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool AdjustTokenPrivileges(nint tokenHandle,
[MarshalAs(UnmanagedType.Bool)] bool disableAllPrivileges,
ref TOKEN_PRIVILEGES newState,
uint bufferLengthInBytes,
ref TOKEN_PRIVILEGES previousState,
out uint returnLengthInBytes);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern bool CreateProcessAsUser(
nint hToken,
string? lpApplicationName,
string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
nint lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool AllocateLocallyUniqueId(out nint pLuid);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = false)]
public static extern SECUR32.WinErrors LsaNtStatusToWinError(SECUR32.WinStatusCodes status);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool GetTokenInformation(
nint TokenHandle,
SECUR32.TOKEN_INFORMATION_CLASS TokenInformationClass,
nint TokenInformation,
uint TokenInformationLength,
out uint ReturnLength);
[DllImport("advapi32.dll", SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool LogonUser(
[MarshalAs(UnmanagedType.LPStr)] string pszUserName,
[MarshalAs(UnmanagedType.LPStr)] string pszDomain,
[MarshalAs(UnmanagedType.LPStr)] string pszPassword,
int dwLogonType,
int dwLogonProvider,
out nint phToken);
[DllImport("advapi32", SetLastError = true), SuppressUnmanagedCodeSecurity]
public static extern bool OpenProcessToken(nint ProcessHandle, int DesiredAccess, ref nint TokenHandle);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern bool DuplicateTokenEx(
nint hExistingToken,
uint dwDesiredAccess,
ref SECURITY_ATTRIBUTES lpTokenAttributes,
SECURITY_IMPERSONATION_LEVEL ImpersonationLevel,
TOKEN_TYPE TokenType,
out nint phNewToken);
[DllImport("advapi32.dll", SetLastError = false)]
public static extern uint LsaNtStatusToWinError(uint status);
#endregion
}

View File

@ -0,0 +1,80 @@
using System;
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
public static class GDI32
{
#region Enums
/// <summary>
/// Specifies a raster-operation code. These codes define how the color data for the
/// source rectangle is to be combined with the color data for the destination
/// rectangle to achieve the final color.
/// </summary>
public enum TernaryRasterOperations : uint
{
/// <summary>dest = source</summary>
SRCCOPY = 0x00CC0020,
/// <summary>dest = source OR dest</summary>
SRCPAINT = 0x00EE0086,
/// <summary>dest = source AND dest</summary>
SRCAND = 0x008800C6,
/// <summary>dest = source XOR dest</summary>
SRCINVERT = 0x00660046,
/// <summary>dest = source AND (NOT dest)</summary>
SRCERASE = 0x00440328,
/// <summary>dest = (NOT source)</summary>
NOTSRCCOPY = 0x00330008,
/// <summary>dest = (NOT src) AND (NOT dest)</summary>
NOTSRCERASE = 0x001100A6,
/// <summary>dest = (source AND pattern)</summary>
MERGECOPY = 0x00C000CA,
/// <summary>dest = (NOT source) OR dest</summary>
MERGEPAINT = 0x00BB0226,
/// <summary>dest = pattern</summary>
PATCOPY = 0x00F00021,
/// <summary>dest = DPSnoo</summary>
PATPAINT = 0x00FB0A09,
/// <summary>dest = pattern XOR dest</summary>
PATINVERT = 0x005A0049,
/// <summary>dest = (NOT dest)</summary>
DSTINVERT = 0x00550009,
/// <summary>dest = BLACK</summary>
BLACKNESS = 0x00000042,
/// <summary>dest = WHITE</summary>
WHITENESS = 0x00FF0062,
/// <summary>
/// Capture window as seen on screen. This includes layered windows
/// such as WPF windows with AllowsTransparency="true"
/// </summary>
CAPTUREBLT = 0x40000000
}
#endregion
#region DLL Imports
[DllImport("gdi32.dll", EntryPoint = "BitBlt", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool BitBlt([In] nint hdc, int nXDest, int nYDest, int nWidth, int nHeight, [In] nint hdcSrc, int nXSrc, int nYSrc, TernaryRasterOperations dwRop);
[DllImport("gdi32.dll")]
public static extern nint CreateDC(string lpszDriver, string lpszDevice, string lpszOutput, nint lpInitData);
[DllImport("GDI32.dll")]
public static extern nint CreateCompatibleBitmap(nint hdc, int nWidth, int nHeight); [DllImport("GDI32.dll")]
public static extern nint CreateCompatibleDC(nint hdc);
[DllImport("GDI32.dll")]
public static extern bool DeleteDC(nint hdc);
[DllImport("GDI32.dll")]
public static extern bool DeleteObject(nint hObject);
[DllImport("GDI32.dll")]
public static extern nint GetDeviceCaps(nint hdc, int nIndex);
[DllImport("GDI32.dll")]
public static extern nint SelectObject(nint hdc, nint hgdiobj);
#endregion
}

View File

@ -0,0 +1,89 @@
using System;
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
public static class Kernel32
{
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(nint hSnapshot);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern nint GetCommandLine();
[DllImport("kernel32.dll")]
public static extern nint GetConsoleWindow();
[return: MarshalAs(UnmanagedType.Bool)]
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern bool GlobalMemoryStatusEx([In, Out] MEMORYSTATUSEX lpBuffer);
[DllImport("kernel32.dll")]
public static extern nint OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId);
[DllImport("kernel32.dll")]
public static extern bool ProcessIdToSessionId(uint dwProcessId, ref uint pSessionId);
[DllImport("kernel32.dll")]
public static extern uint WTSGetActiveConsoleSessionId();
/// <summary>
/// contains information about the current state of both physical and virtual memory, including extended memory
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public class MEMORYSTATUSEX
{
/// <summary>
/// Size of the structure, in bytes. You must set this member before calling GlobalMemoryStatusEx.
/// </summary>
public uint dwLength;
/// <summary>
/// Number between 0 and 100 that specifies the approximate percentage of physical memory that is in use (0 indicates no memory use and 100 indicates full memory use).
/// </summary>
public uint dwMemoryLoad;
/// <summary>
/// Total size of physical memory, in bytes.
/// </summary>
public ulong ullTotalPhys;
/// <summary>
/// Size of physical memory available, in bytes.
/// </summary>
public ulong ullAvailPhys;
/// <summary>
/// Size of the committed memory limit, in bytes. This is physical memory plus the size of the page file, minus a small overhead.
/// </summary>
public ulong ullTotalPageFile;
/// <summary>
/// Size of available memory to commit, in bytes. The limit is ullTotalPageFile.
/// </summary>
public ulong ullAvailPageFile;
/// <summary>
/// Total size of the user mode portion of the virtual address space of the calling process, in bytes.
/// </summary>
public ulong ullTotalVirtual;
/// <summary>
/// Size of unreserved and uncommitted memory in the user mode portion of the virtual address space of the calling process, in bytes.
/// </summary>
public ulong ullAvailVirtual;
/// <summary>
/// Size of unreserved and uncommitted memory in the extended portion of the virtual address space of the calling process, in bytes.
/// </summary>
public ulong ullAvailExtendedVirtual;
/// <summary>
/// Initializes a new instance of the <see cref="T:MEMORYSTATUSEX"/> class.
/// </summary>
public MEMORYSTATUSEX()
{
dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
}
}
}

View File

@ -0,0 +1,376 @@
using Immense.RemoteControl.Desktop.Shared.Native.Windows;
using Microsoft.Win32.SafeHandles;
using System;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
public static class SECUR32
{
public enum WinStatusCodes : uint
{
STATUS_SUCCESS = 0
}
public enum WinErrors : uint
{
NO_ERROR = 0,
}
public enum WinLogonType
{
LOGON32_LOGON_INTERACTIVE = 2,
LOGON32_LOGON_NETWORK = 3,
LOGON32_LOGON_BATCH = 4,
LOGON32_LOGON_SERVICE = 5,
LOGON32_LOGON_UNLOCK = 7,
LOGON32_LOGON_NETWORK_CLEARTEXT = 8,
LOGON32_LOGON_NEW_CREDENTIALS = 9
}
// SECURITY_LOGON_TYPE
public enum SecurityLogonType
{
Interactive = 2, // Interactively logged on (locally or remotely)
Network, // Accessing system via network
Batch, // Started via a batch queue
Service, // Service started by service controller
Proxy, // Proxy logon
Unlock, // Unlock workstation
NetworkCleartext, // Network logon with cleartext credentials
NewCredentials, // Clone caller, new default credentials
RemoteInteractive, // Remote, yet interactive. Terminal server
CachedInteractive, // Try cached credentials without hitting the net.
CachedRemoteInteractive, // Same as RemoteInteractive, this is used internally for auditing purpose
CachedUnlock // Cached Unlock workstation
}
[StructLayout(LayoutKind.Sequential)]
public struct LSA_UNICODE_STRING
{
public UInt16 Length;
public UInt16 MaximumLength;
public IntPtr Buffer;
}
[StructLayout(LayoutKind.Sequential)]
public struct TOKEN_SOURCE
{
public TOKEN_SOURCE(string name)
{
SourceName = new byte[8];
System.Text.Encoding.GetEncoding(1252).GetBytes(name, 0, name.Length, SourceName, 0);
if (!ADVAPI32.AllocateLocallyUniqueId(out SourceIdentifier))
throw new System.ComponentModel.Win32Exception();
}
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)] public byte[] SourceName;
public IntPtr SourceIdentifier;
}
[StructLayout(LayoutKind.Sequential)]
public struct KERB_INTERACTIVE_LOGON
{
public KERB_LOGON_SUBMIT_TYPE MessageType;
public string LogonDomainName;
public string UserName;
public string Password;
}
public enum KERB_LOGON_SUBMIT_TYPE
{
KerbInteractiveLogon = 2,
KerbSmartCardLogon = 6,
KerbWorkstationUnlockLogon = 7,
KerbSmartCardUnlockLogon = 8,
KerbProxyLogon = 9,
KerbTicketLogon = 10,
KerbTicketUnlockLogon = 11,
KerbS4ULogon = 12,
KerbCertificateLogon = 13,
KerbCertificateS4ULogon = 14,
KerbCertificateUnlockLogon = 15
}
public enum TOKEN_INFORMATION_CLASS
{
/// <summary>
    /// The buffer receives a TOKEN_USER structure that contains the user account of the token.
    /// </summary>
TokenUser = 1,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS structure that contains the group accounts associated with the token.
    /// </summary>
TokenGroups,
/// <summary>
    /// The buffer receives a TOKEN_PRIVILEGES structure that contains the privileges of the token.
    /// </summary>
TokenPrivileges,
/// <summary>
    /// The buffer receives a TOKEN_OWNER structure that contains the default owner security identifier (SID) for newly created objects.
    /// </summary>
TokenOwner,
/// <summary>
    /// The buffer receives a TOKEN_PRIMARY_GROUP structure that contains the default primary group SID for newly created objects.
    /// </summary>
TokenPrimaryGroup,
/// <summary>
    /// The buffer receives a TOKEN_DEFAULT_DACL structure that contains the default DACL for newly created objects.
    /// </summary>
TokenDefaultDacl,
/// <summary>
    /// The buffer receives a TOKEN_SOURCE structure that contains the source of the token. TOKEN_QUERY_SOURCE access is needed to retrieve this information.
    /// </summary>
TokenSource,
/// <summary>
    /// The buffer receives a TOKEN_TYPE value that indicates whether the token is a primary or impersonation token.
    /// </summary>
TokenType,
/// <summary>
    /// The buffer receives a SECURITY_IMPERSONATION_LEVEL value that indicates the impersonation level of the token. If the access token is not an impersonation token, the function fails.
    /// </summary>
TokenImpersonationLevel,
/// <summary>
    /// The buffer receives a TOKEN_STATISTICS structure that contains various token statistics.
    /// </summary>
TokenStatistics,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS structure that contains the list of restricting SIDs in a restricted token.
    /// </summary>
TokenRestrictedSids,
/// <summary>
    /// The buffer receives a DWORD value that indicates the Terminal Services session identifier that is associated with the token.
    /// </summary>
TokenSessionId,
/// <summary>
    /// The buffer receives a TOKEN_GROUPS_AND_PRIVILEGES structure that contains the user SID, the group accounts, the restricted SIDs, and the authentication ID associated with the token.
    /// </summary>
TokenGroupsAndPrivileges,
/// <summary>
    /// Reserved.
    /// </summary>
TokenSessionReference,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token includes the SANDBOX_INERT flag.
    /// </summary>
TokenSandBoxInert,
/// <summary>
    /// Reserved.
    /// </summary>
TokenAuditPolicy,
/// <summary>
    /// The buffer receives a TOKEN_ORIGIN value.
    /// </summary>
TokenOrigin,
/// <summary>
    /// The buffer receives a TOKEN_ELEVATION_TYPE value that specifies the elevation level of the token.
    /// </summary>
TokenElevationType,
/// <summary>
    /// The buffer receives a TOKEN_LINKED_TOKEN structure that contains a handle to another token that is linked to this token.
    /// </summary>
TokenLinkedToken,
/// <summary>
    /// The buffer receives a TOKEN_ELEVATION structure that specifies whether the token is elevated.
    /// </summary>
TokenElevation,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token has ever been filtered.
    /// </summary>
TokenHasRestrictions,
/// <summary>
    /// The buffer receives a TOKEN_ACCESS_INFORMATION structure that specifies security information contained in the token.
    /// </summary>
TokenAccessInformation,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if virtualization is allowed for the token.
    /// </summary>
TokenVirtualizationAllowed,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if virtualization is enabled for the token.
    /// </summary>
TokenVirtualizationEnabled,
/// <summary>
    /// The buffer receives a TOKEN_MANDATORY_LABEL structure that specifies the token's integrity level.
    /// </summary>
TokenIntegrityLevel,
/// <summary>
    /// The buffer receives a DWORD value that is nonzero if the token has the UIAccess flag set.
    /// </summary>
TokenUIAccess,
/// <summary>
    /// The buffer receives a TOKEN_MANDATORY_POLICY structure that specifies the token's mandatory integrity policy.
    /// </summary>
TokenMandatoryPolicy,
/// <summary>
    /// The buffer receives the token's logon security identifier (SID).
    /// </summary>
TokenLogonSid,
/// <summary>
    /// The maximum value for this enumeration
    /// </summary>
MaxTokenInfoClass
}
[StructLayout(LayoutKind.Sequential)]
public struct QUOTA_LIMITS
{
readonly UInt32 PagedPoolLimit;
readonly UInt32 NonPagedPoolLimit;
readonly UInt32 MinimumWorkingSetSize;
readonly UInt32 MaximumWorkingSetSize;
readonly UInt32 PagefileLimit;
readonly Int64 TimeLimit;
}
[StructLayout(LayoutKind.Sequential)]
public struct LSA_STRING
{
public UInt16 Length;
public UInt16 MaximumLength;
public /*PCHAR*/ IntPtr Buffer;
}
[DllImport("secur32.dll", SetLastError = true)]
public static extern WinStatusCodes LsaLogonUser(
[In] IntPtr LsaHandle,
[In] ref LSA_STRING OriginName,
[In] SecurityLogonType LogonType,
[In] UInt32 AuthenticationPackage,
[In] IntPtr AuthenticationInformation,
[In] UInt32 AuthenticationInformationLength,
[In] /*PTOKEN_GROUPS*/ IntPtr LocalGroups,
[In] ref TOKEN_SOURCE SourceContext,
[Out] /*PVOID*/ out IntPtr ProfileBuffer,
[Out] out UInt32 ProfileBufferLength,
[Out] out Int64 LogonId,
[Out] out IntPtr Token,
[Out] out QUOTA_LIMITS Quotas,
[Out] out WinStatusCodes SubStatus
);
[DllImport("secur32.dll", SetLastError = true)]
public static extern WinStatusCodes LsaRegisterLogonProcess(
IntPtr LogonProcessName,
out IntPtr LsaHandle,
out ulong SecurityMode
);
[DllImport("secur32.dll", SetLastError = false)]
public static extern WinStatusCodes LsaLookupAuthenticationPackage([In] IntPtr LsaHandle, [In] ref LSA_STRING PackageName, [Out] out UInt32 AuthenticationPackage);
[DllImport("secur32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[ResourceExposure(ResourceScope.None)]
internal static extern int LsaConnectUntrusted(
[In, Out] ref SafeLsaLogonProcessHandle LsaHandle);
[DllImport("secur32.dll", SetLastError = false)]
public static extern WinStatusCodes LsaConnectUntrusted([Out] out IntPtr LsaHandle);
[System.Security.SecurityCritical] // auto-generated
internal sealed class SafeLsaLogonProcessHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private SafeLsaLogonProcessHandle() : base(true) { }
// 0 is an Invalid Handle
internal SafeLsaLogonProcessHandle(IntPtr handle) : base(true)
{
SetHandle(handle);
}
internal static SafeLsaLogonProcessHandle InvalidHandle
{
get { return new SafeLsaLogonProcessHandle(IntPtr.Zero); }
}
[System.Security.SecurityCritical]
protected override bool ReleaseHandle()
{
// LsaDeregisterLogonProcess returns an NTSTATUS
return LsaDeregisterLogonProcess(handle) >= 0;
}
}
[DllImport("secur32.dll", SetLastError = true)]
[ResourceExposure(ResourceScope.None)]
internal static extern int LsaDeregisterLogonProcess(IntPtr handle);
public static void CreateNewSession()
{
var kli = new SECUR32.KERB_INTERACTIVE_LOGON()
{
MessageType = SECUR32.KERB_LOGON_SUBMIT_TYPE.KerbInteractiveLogon,
UserName = "",
Password = ""
};
IntPtr kerbLogInfo;
SECUR32.LSA_STRING logonProc = new()
{
Buffer = Marshal.StringToHGlobalAuto("InstaLogon"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon"))
};
SECUR32.LSA_STRING originName = new()
{
Buffer = Marshal.StringToHGlobalAuto("InstaLogon"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("InstaLogon"))
};
SECUR32.LSA_STRING authPackage = new()
{
Buffer = Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A"),
Length = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A")),
MaximumLength = (ushort)Marshal.SizeOf(Marshal.StringToHGlobalAuto("MICROSOFT_KERBEROS_NAME_A"))
};
IntPtr hLogonProc = Marshal.AllocHGlobal(Marshal.SizeOf(logonProc));
Marshal.StructureToPtr(logonProc, hLogonProc, false);
ADVAPI32.AllocateLocallyUniqueId(out IntPtr pluid);
LsaConnectUntrusted(out IntPtr lsaHan);
//SECUR32.LsaRegisterLogonProcess(hLogonProc, out lsaHan, out secMode);
SECUR32.LsaLookupAuthenticationPackage(lsaHan, ref authPackage, out uint authPackID);
kerbLogInfo = Marshal.AllocHGlobal(Marshal.SizeOf(kli));
Marshal.StructureToPtr(kli, kerbLogInfo, false);
var ts = new SECUR32.TOKEN_SOURCE("Insta");
SECUR32.LsaLogonUser(
lsaHan,
ref originName,
SECUR32.SecurityLogonType.Interactive,
authPackID,
kerbLogInfo,
(uint)Marshal.SizeOf(kerbLogInfo),
IntPtr.Zero,
ref ts,
out IntPtr profBuf,
out uint profBufLen,
out long logonID,
out IntPtr logonToken,
out QUOTA_LIMITS quotas,
out WinStatusCodes subStatus);
}
}

View File

@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
// https://docs.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-isos
public class Shlwapi
{
[DllImport("shlwapi.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsOS(OsType osType);
}
public enum OsType
{
OS_WINDOWS = 0,
OS_NT = 1,
OS_WIN95ORGREATER = 2,
OS_NT4ORGREATER = 3,
OS_WIN98ORGREATER = 5,
OS_WIN98_GOLD = 6,
OS_WIN2000ORGREATER = 7,
OS_WIN2000PRO = 8,
OS_WIN2000SERVER = 9,
OS_WIN2000ADVSERVER = 10,
OS_WIN2000DATACENTER = 11,
OS_WIN2000TERMINAL = 12,
OS_EMBEDDED = 13,
OS_TERMINALCLIENT = 14,
OS_TERMINALREMOTEADMIN = 15,
OS_WIN95_GOLD = 16,
OS_MEORGREATER = 17,
OS_XPORGREATER = 18,
OS_HOME = 19,
OS_PROFESSIONAL = 20,
OS_DATACENTER = 21,
OS_ADVSERVER = 22,
OS_SERVER = 23,
OS_TERMINALSERVER = 24,
OS_PERSONALTERMINALSERVER = 25,
OS_FASTUSERSWITCHING = 26,
OS_WELCOMELOGONUI = 27,
OS_DOMAINMEMBER = 28,
OS_ANYSERVER = 29,
OS_WOW6432 = 30,
OS_WEBSERVER = 31,
OS_SMALLBUSINESSSERVER = 32,
OS_TABLETPC = 33,
OS_SERVERADMINUI = 34,
OS_MEDIACENTER = 35,
OS_APPLIANCE = 36,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
using System;
using System.Runtime.InteropServices;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
public static class WTSAPI32
{
public static nint WTS_CURRENT_SERVER_HANDLE = nint.Zero;
public enum WTS_CONNECTSTATE_CLASS
{
WTSActive,
WTSConnected,
WTSConnectQuery,
WTSShadow,
WTSDisconnected,
WTSIdle,
WTSListen,
WTSReset,
WTSDown,
WTSInit
}
public enum WTS_INFO_CLASS
{
WTSInitialProgram,
WTSApplicationName,
WTSWorkingDirectory,
WTSOEMId,
WTSSessionId,
WTSUserName,
WTSWinStationName,
WTSDomainName,
WTSConnectState,
WTSClientBuildNumber,
WTSClientName,
WTSClientDirectory,
WTSClientProductId,
WTSClientHardwareId,
WTSClientAddress,
WTSClientDisplay,
WTSClientProtocolType,
WTSIdleTime,
WTSLogonTime,
WTSIncomingBytes,
WTSOutgoingBytes,
WTSIncomingFrames,
WTSOutgoingFrames,
WTSClientInfo,
WTSSessionInfo
}
[DllImport("wtsapi32.dll", SetLastError = true)]
public static extern int WTSEnumerateSessions(
nint hServer,
int Reserved,
int Version,
ref nint ppSessionInfo,
ref int pCount);
[DllImport("wtsapi32.dll", ExactSpelling = true, SetLastError = false)]
public static extern void WTSFreeMemory(nint memory);
[DllImport("Wtsapi32.dll")]
public static extern bool WTSQuerySessionInformation(nint hServer, uint sessionId, WTS_INFO_CLASS wtsInfoClass, out nint ppBuffer, out uint pBytesReturned);
[DllImport("wtsapi32.dll", SetLastError = true)]
static extern nint WTSOpenServer(string pServerName);
[StructLayout(LayoutKind.Sequential)]
public struct WTS_SESSION_INFO
{
public uint SessionID;
[MarshalAs(UnmanagedType.LPStr)]
public string pWinStationName;
public WTS_CONNECTSTATE_CLASS State;
}
}

View File

@ -0,0 +1,281 @@
using Immense.RemoteControl.Shared.Models;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Text;
using static Immense.RemoteControl.Desktop.Shared.Native.Windows.ADVAPI32;
using static Immense.RemoteControl.Desktop.Shared.Native.Windows.User32;
namespace Immense.RemoteControl.Desktop.Shared.Native.Windows;
// TODO: Use https://github.com/microsoft/CsWin32 for all p/invokes.
public class Win32Interop
{
public static List<WindowsSession> GetActiveSessions()
{
var sessions = new List<WindowsSession>();
var consoleSessionId = Kernel32.WTSGetActiveConsoleSessionId();
sessions.Add(new WindowsSession()
{
Id = consoleSessionId,
Type = WindowsSessionType.Console,
Name = "Console",
Username = GetUsernameFromSessionId(consoleSessionId)
});
nint ppSessionInfo = nint.Zero;
var count = 0;
var enumSessionResult = WTSAPI32.WTSEnumerateSessions(WTSAPI32.WTS_CURRENT_SERVER_HANDLE, 0, 1, ref ppSessionInfo, ref count);
var dataSize = Marshal.SizeOf(typeof(WTSAPI32.WTS_SESSION_INFO));
var current = ppSessionInfo;
if (enumSessionResult != 0)
{
for (int i = 0; i < count; i++)
{
var wtsInfo = Marshal.PtrToStructure(current, typeof(WTSAPI32.WTS_SESSION_INFO));
if (wtsInfo is null)
{
continue;
}
var sessionInfo = (WTSAPI32.WTS_SESSION_INFO)wtsInfo;
current += dataSize;
if (sessionInfo.State == WTSAPI32.WTS_CONNECTSTATE_CLASS.WTSActive && sessionInfo.SessionID != consoleSessionId)
{
sessions.Add(new WindowsSession()
{
Id = sessionInfo.SessionID,
Name = sessionInfo.pWinStationName,
Type = WindowsSessionType.RDP,
Username = GetUsernameFromSessionId(sessionInfo.SessionID)
});
}
}
}
return sessions;
}
public static string GetCommandLine()
{
var commandLinePtr = Kernel32.GetCommandLine();
return Marshal.PtrToStringAuto(commandLinePtr) ?? string.Empty;
}
public static bool GetCurrentDesktop([NotNullWhen(true)] out string? desktopName)
{
desktopName = null;
var inputDesktop = OpenInputDesktop();
try
{
if (TryGetDesktopName(inputDesktop, out desktopName))
{
return true;
}
return false;
}
finally
{
CloseDesktop(inputDesktop);
}
}
public static string GetUsernameFromSessionId(uint sessionId)
{
var username = string.Empty;
if (WTSAPI32.WTSQuerySessionInformation(nint.Zero, sessionId, WTSAPI32.WTS_INFO_CLASS.WTSUserName, out var buffer, out var strLen) && strLen > 1)
{
username = Marshal.PtrToStringAnsi(buffer);
WTSAPI32.WTSFreeMemory(buffer);
}
return username ?? string.Empty;
}
public static nint OpenInputDesktop()
{
return User32.OpenInputDesktop(0, true, ACCESS_MASK.GENERIC_ALL);
}
public static bool CreateInteractiveSystemProcess(
string commandLine,
int targetSessionId,
bool forceConsoleSession,
string desktopName,
bool hiddenWindow,
out PROCESS_INFORMATION procInfo)
{
uint winlogonPid = 0;
var hUserTokenDup = nint.Zero;
var hPToken = nint.Zero;
var hProcess = nint.Zero;
procInfo = new PROCESS_INFORMATION();
// If not force console, find target session. If not present,
// use last active session.
var dwSessionId = Kernel32.WTSGetActiveConsoleSessionId();
if (!forceConsoleSession)
{
var activeSessions = GetActiveSessions();
if (activeSessions.Any(x => x.Id == targetSessionId))
{
dwSessionId = (uint)targetSessionId;
}
else
{
dwSessionId = activeSessions.Last().Id;
}
}
// Obtain the process ID of the winlogon process that is running within the currently active session.
var processes = Process.GetProcessesByName("winlogon");
foreach (Process p in processes)
{
if ((uint)p.SessionId == dwSessionId)
{
winlogonPid = (uint)p.Id;
}
}
// Obtain a handle to the winlogon process.
hProcess = Kernel32.OpenProcess(MAXIMUM_ALLOWED, false, winlogonPid);
// Obtain a handle to the access token of the winlogon process.
if (!OpenProcessToken(hProcess, TOKEN_DUPLICATE, ref hPToken))
{
Kernel32.CloseHandle(hProcess);
return false;
}
// Security attibute structure used in DuplicateTokenEx and CreateProcessAsUser.
var sa = new SECURITY_ATTRIBUTES();
sa.Length = Marshal.SizeOf(sa);
// Copy the access token of the winlogon process; the newly created token will be a primary token.
if (!DuplicateTokenEx(hPToken, MAXIMUM_ALLOWED, ref sa, SECURITY_IMPERSONATION_LEVEL.SecurityIdentification, TOKEN_TYPE.TokenPrimary, out hUserTokenDup))
{
Kernel32.CloseHandle(hProcess);
Kernel32.CloseHandle(hPToken);
return false;
}
// By default, CreateProcessAsUser creates a process on a non-interactive window station, meaning
// the window station has a desktop that is invisible and the process is incapable of receiving
// user input. To remedy this we set the lpDesktop parameter to indicate we want to enable user
// interaction with the new process.
var si = new STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = @"winsta0\" + desktopName;
// Flags that specify the priority and creation method of the process.
uint dwCreationFlags;
if (hiddenWindow)
{
dwCreationFlags = NORMAL_PRIORITY_CLASS | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW;
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = 0;
}
else
{
dwCreationFlags = NORMAL_PRIORITY_CLASS | CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE;
}
// Create a new process in the current user's logon session.
var result = CreateProcessAsUser(
hUserTokenDup,
null,
commandLine,
ref sa,
ref sa,
false,
dwCreationFlags,
nint.Zero,
null,
ref si,
out procInfo);
// Invalidate the handles.
Kernel32.CloseHandle(hProcess);
Kernel32.CloseHandle(hPToken);
Kernel32.CloseHandle(hUserTokenDup);
return result;
}
public static void SetMonitorState(MonitorState state)
{
SendMessage(0xFFFF, 0x112, 0xF170, (int)state);
}
public static MessageBoxResult ShowMessageBox(nint owner,
string message,
string caption,
MessageBoxType messageBoxType)
{
return (MessageBoxResult)MessageBox(owner, message, caption, (long)messageBoxType);
}
public static bool SwitchToInputDesktop()
{
try
{
var inputDesktop = OpenInputDesktop();
try
{
if (inputDesktop == nint.Zero)
{
return false;
}
return SetThreadDesktop(inputDesktop);
}
finally
{
CloseDesktop(inputDesktop);
}
}
catch
{
return false;
}
}
public static void SetConsoleWindowVisibility(bool isVisible)
{
var handle = Kernel32.GetConsoleWindow();
if (isVisible)
{
ShowWindow(handle, (int)SW.SW_SHOW);
}
else
{
ShowWindow(handle, (int)SW.SW_HIDE);
}
Kernel32.CloseHandle(handle);
}
public static bool TryGetDesktopName(nint desktopHandle, [NotNullWhen(true)] out string? desktopName)
{
var deskBytes = new byte[256];
if (!GetUserObjectInformationW(desktopHandle, UOI_NAME, deskBytes, 256, out uint lenNeeded))
{
desktopName = string.Empty;
return false;
}
desktopName = Encoding.Unicode
.GetString(deskBytes.Take((int)lenNeeded).ToArray())
.Replace("\0", "");
return true;
}
}

View File

@ -0,0 +1,6 @@
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IAppStartup
{
Task Run();
}

View File

@ -0,0 +1,7 @@
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IAudioCapturer
{
event EventHandler<byte[]> AudioSampleReady;
void ToggleAudio(bool toggleOn);
}

View File

@ -0,0 +1,11 @@
using Immense.RemoteControl.Shared.Models;
using Remotely.Shared.Entities;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IBrandingProvider
{
BrandingInfo CurrentBranding { get; }
Task Initialize();
void SetBrandingInfo(BrandingInfo brandingInfo);
}

View File

@ -0,0 +1,11 @@
using Remotely.Shared.Models;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IChatUiService
{
event EventHandler ChatWindowClosed;
void ShowChatWindow(string organizationName, StreamWriter writer);
Task ReceiveChat(ChatMessage chatMessage);
}

View File

@ -0,0 +1,10 @@
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IClipboardService
{
event EventHandler<string> ClipboardTextChanged;
void BeginWatching();
Task SetText(string clipboardText);
}

View File

@ -0,0 +1,11 @@
using Immense.RemoteControl.Shared.Models;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface ICursorIconWatcher
{
[Obsolete("This should be replaced with a message published by IMessenger.")]
event EventHandler<CursorInfo> OnChange;
CursorInfo GetCurrentCursor();
}

View File

@ -0,0 +1,13 @@
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Desktop.Shared.ViewModels;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IFileTransferService
{
string GetBaseDirectory();
Task ReceiveFile(byte[] buffer, string fileName, string messageId, bool endOfFile, bool startOfFile);
void OpenFileTransferWindow(IViewer viewer);
Task UploadFile(FileUpload file, IViewer viewer, Action<double> progressUpdateCallback, CancellationToken cancelToken);
}

View File

@ -0,0 +1,17 @@
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Services;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IKeyboardMouseInput
{
void Init();
void SendKeyDown(string key);
void SendKeyUp(string key);
void SendMouseMove(double percentX, double percentY, IViewer viewer);
void SendMouseWheel(int deltaY);
void SendText(string transferText);
void ToggleBlockInput(bool toggleOn);
void SetKeyStatesUp();
void SendMouseButtonAction(int button, ButtonAction buttonAction, double percentX, double percentY, IViewer viewer);
}

View File

@ -0,0 +1,10 @@
using Immense.RemoteControl.Shared.Enums;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IRemoteControlAccessService
{
bool IsPromptOpen { get; }
Task<PromptForAccessResult> PromptForAccess(string requesterName, string organizationName);
}

View File

@ -0,0 +1,29 @@
using Remotely.Shared.Primitives;
using SkiaSharp;
using System.Drawing;
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IScreenCapturer : IDisposable
{
event EventHandler<Rectangle> ScreenChanged;
bool CaptureFullscreen { get; set; }
Rectangle CurrentScreenBounds { get; }
bool IsGpuAccelerated { get; }
string SelectedScreen { get; }
IEnumerable<string> GetDisplayNames();
SKRect GetFrameDiffArea();
Result<SKBitmap> GetImageDiff();
Result<SKBitmap> GetNextFrame();
int GetScreenCount();
Rectangle GetVirtualScreenBounds();
void Init();
void SetSelectedScreen(string displayName);
}

View File

@ -0,0 +1,6 @@
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface ISessionIndicator
{
void Show();
}

View File

@ -0,0 +1,6 @@
namespace Immense.RemoteControl.Desktop.Shared.Abstractions;
public interface IShutdownService
{
Task Shutdown();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -4,24 +4,39 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\favicon.ico" />
<None Remove="Assets\Remotely_Icon.png" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\Remotely_Icon.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Bitbound.SimpleMessenger" Version="2.2.1" />
<PackageReference Include="CommunityToolkit.Diagnostics" Version="8.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="2.88.8" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.CommandLine.NamingConventionBinder" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Desktop.Native\Desktop.Native.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
<ProjectReference Include="..\submodules\Immense.RemoteControl\Immense.RemoteControl.Desktop.Shared\Immense.RemoteControl.Desktop.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\favicon.ico" />
</ItemGroup>
<ItemGroup>
<Folder Include="Native\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace Immense.RemoteControl.Desktop.Shared.Enums;
public enum AppMode
{
Unattended,
Attended,
Chat
}

View File

@ -0,0 +1,7 @@
namespace Immense.RemoteControl.Desktop.Shared.Enums;
public enum ButtonAction
{
Down,
Up
}

View File

@ -0,0 +1,11 @@
using SkiaSharp;
namespace Immense.RemoteControl.Desktop.Shared.Extensions;
public static class SKBitmapExtensions
{
public static SKRect ToRectangle(this SKBitmap bitmap)
{
return new SKRect(0, 0, bitmap.Width, bitmap.Height);
}
}

View File

@ -0,0 +1,11 @@
namespace Immense.RemoteControl.Desktop.Shared.Messages;
public class AppStateHostChangedMessage
{
public AppStateHostChangedMessage(string newHost)
{
NewHost = newHost;
}
public string NewHost { get; }
}

View File

@ -0,0 +1,2 @@
namespace Immense.RemoteControl.Desktop.Shared.Messages;
public record DisplaySettingsChangedMessage();

View File

@ -0,0 +1,13 @@
using Immense.RemoteControl.Shared.Enums;
namespace Immense.RemoteControl.Desktop.Shared.Messages;
public class WindowsSessionEndingMessage
{
public WindowsSessionEndingMessage(SessionEndReasonsEx reason)
{
Reason = reason;
}
public SessionEndReasonsEx Reason { get; }
}

View File

@ -0,0 +1,15 @@
using Immense.RemoteControl.Shared.Enums;
namespace Immense.RemoteControl.Desktop.Shared.Messages;
public class WindowsSessionSwitchedMessage
{
public WindowsSessionSwitchedMessage(SessionSwitchReasonEx reason, int sessionId)
{
Reason = reason;
SessionId = sessionId;
}
public SessionSwitchReasonEx Reason { get; }
public int SessionId { get; }
}

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Remotely_Desktop")]

View File

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.Shared.Reactive;
public class AsyncRelayCommand : ICommand
{
private readonly Func<bool> _canExecute;
private readonly Func<Task> _execute;
public AsyncRelayCommand(Func<Task> execute)
{
_execute = execute;
_canExecute = () => true;
}
public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute.Invoke();
}
public void Execute(object? parameter)
{
_execute.Invoke();
}
public void NotifyCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
public class AsyncRelayCommand<T> : ICommand
{
private readonly Func<T?, bool> _canExecute;
private readonly Func<T?, Task> _execute;
public AsyncRelayCommand(Func<T?, Task> execute)
{
_execute = execute;
_canExecute = (parameter) => true;
}
public AsyncRelayCommand(Func<T?, Task> execute, Func<T?, bool> canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
if (parameter is null)
{
return _canExecute.Invoke(default);
}
if (parameter is not T typedParam)
{
throw new InvalidOperationException("Paramter is not of the correct type.");
}
return _canExecute.Invoke(typedParam);
}
// Async void is una*void*able here (heh, heh) due to ICommand's interface.
// Though we shouldn't need to in modern .NET, we're handling UnobservedTaskException
// in IServiceProviderExtensions.UseRemoteControl. In older versions of .NET, this
// would have been required to prevent the app from terminating.
public async void Execute(object? parameter)
{
if (parameter is null)
{
await _execute.Invoke(default);
return;
}
if (parameter is not T typedParam)
{
throw new InvalidOperationException("Paramter is not of the correct type.");
}
await _execute.Invoke(typedParam);
}
public void NotifyCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@ -0,0 +1,45 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Immense.RemoteControl.Desktop.Shared.Reactive;
public class ObservableObject : INotifyPropertyChanged
{
private readonly ConcurrentDictionary<string, object?> _backingFields = new();
public event PropertyChangedEventHandler? PropertyChanged;
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected T? Get<T>([CallerMemberName] string propertyName = "")
{
if (_backingFields.TryGetValue(propertyName, out var value) &&
value is T typedValue)
{
return typedValue;
}
return default;
}
protected T Get<T>(T defaultValue, [CallerMemberName] string propertyName = "")
{
if (_backingFields.TryGetValue(propertyName, out var value) &&
value is T typedValue)
{
return typedValue;
}
return defaultValue;
}
protected void Set<T>(T newValue, [CallerMemberName] string propertyName = "")
{
_backingFields.AddOrUpdate(propertyName, newValue, (k, v) => newValue);
NotifyPropertyChanged(propertyName);
}
}

View File

@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.Shared.Reactive;
public class RelayCommand : ICommand
{
private readonly Func<bool> _canExecute;
private readonly Action _execute;
public RelayCommand(Action execute)
{
_execute = execute;
_canExecute = () => true;
}
public RelayCommand(Action execute, Func<bool> canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return _canExecute.Invoke();
}
public void Execute(object? parameter)
{
_execute.Invoke();
}
public void NotifyCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
public class RelayCommand<T> : ICommand
{
private readonly Func<T?, bool> _canExecute;
private readonly Action<T?> _execute;
public RelayCommand(Action<T?> execute)
{
_execute = execute;
_canExecute = (parameter) => true;
}
public RelayCommand(Action<T?> execute, Func<T?, bool> canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
if (parameter is null)
{
return _canExecute.Invoke(default);
}
if (parameter is not T typedParam)
{
throw new InvalidOperationException(
"Parameter is not of the correct type. " +
$"Expected type {typeof(T)}. " +
$"Received type {parameter.GetType()}.");
}
return _canExecute.Invoke(typedParam);
}
public void Execute(object? parameter)
{
if (parameter is null)
{
_execute.Invoke(default);
return;
}
if (parameter is not T typedParam)
{
throw new InvalidOperationException(
"Parameter is not of the correct type. " +
$"Expected type {typeof(T)}. " +
$"Received type {parameter.GetType()}.");
}
_execute.Invoke(typedParam);
}
public void NotifyCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@ -0,0 +1,190 @@
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Messages;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
using Bitbound.SimpleMessenger;
using System.Collections.Concurrent;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IAppState
{
event EventHandler<ScreenCastRequest> ScreenCastRequested;
event EventHandler<IViewer> ViewerAdded;
event EventHandler<string> ViewerRemoved;
string AccessKey { get; }
Dictionary<string, string> ArgDict { get; }
string Host { get; set; }
bool IsElevate { get; }
bool IsRelaunch { get; }
AppMode Mode { get; set; }
string OrganizationName { get; }
string PipeName { get; }
string[] RelaunchViewers { get; }
string RequesterName { get; }
string SessionId { get; }
ConcurrentDictionary<string, IViewer> Viewers { get; }
void Configure(
string host,
AppMode mode,
string sessionId,
string accessKey,
string requesterName,
string organizationName,
string pipeName,
bool relaunch,
string viewers,
bool elevate);
void InvokeScreenCastRequested(ScreenCastRequest viewerIdAndRequesterName);
void InvokeViewerAdded(IViewer viewer);
void InvokeViewerRemoved(string viewerID);
void UpdateHost(string host);
}
public class AppState : IAppState
{
private readonly Dictionary<string, string> _argDict = new();
private readonly ILogger<AppState> _logger;
private readonly IMessenger _messenger;
private string _host = string.Empty;
private bool _isConfigured;
public AppState(IMessenger messenger, ILogger<AppState> logger)
{
_messenger = messenger;
_logger = logger;
}
public event EventHandler<ScreenCastRequest>? ScreenCastRequested;
public event EventHandler<IViewer>? ViewerAdded;
public event EventHandler<string>? ViewerRemoved;
public string AccessKey { get; private set; } = string.Empty;
public Dictionary<string, string> ArgDict
{
get
{
if (!_argDict.Any())
{
ProcessArgs();
}
return _argDict;
}
}
public string Host
{
get => _host;
set
{
_host = value?.Trim()?.TrimEnd('/') ?? string.Empty;
_messenger.Send(new AppStateHostChangedMessage(_host));
}
}
public bool IsElevate { get; private set; }
public bool IsRelaunch { get; private set; }
public AppMode Mode { get; set; }
public string OrganizationName { get; private set; } = string.Empty;
public string PipeName { get; private set; } = string.Empty;
public string[] RelaunchViewers { get; private set; } = Array.Empty<string>();
public string RequesterName { get; private set; } = string.Empty;
public string SessionId { get; private set; } = string.Empty;
public ConcurrentDictionary<string, IViewer> Viewers { get; } = new();
public void Configure(
string host,
AppMode mode,
string sessionId,
string accessKey,
string requesterName,
string organizationName,
string pipeName,
bool relaunch,
string viewers,
bool elevate)
{
if (_isConfigured)
{
throw new InvalidOperationException("AppState has already been configured.");
}
_isConfigured = true;
Host = host;
Mode = mode;
SessionId = sessionId;
AccessKey = accessKey;
RequesterName = requesterName;
OrganizationName = organizationName;
PipeName = pipeName;
IsRelaunch = relaunch;
RelaunchViewers = viewers.Split(",");
IsElevate = elevate;
}
public void InvokeScreenCastRequested(ScreenCastRequest viewerIdAndRequesterName)
{
ScreenCastRequested?.Invoke(null, viewerIdAndRequesterName);
}
public void InvokeViewerAdded(IViewer viewer)
{
ViewerAdded?.Invoke(null, viewer);
}
public void InvokeViewerRemoved(string viewerID)
{
ViewerRemoved?.Invoke(null, viewerID);
}
public void UpdateHost(string host)
{
Host = host;
}
private void ProcessArgs()
{
var cmdArgs = Environment.GetCommandLineArgs();
var args = Environment.GetCommandLineArgs()
.SkipWhile(x => !x.StartsWith("-"))
.ToArray();
for (var i = 0; i < args.Length; i += 2)
{
try
{
var key = args[i];
if (key != null)
{
if (!key.Contains('-'))
{
_logger.LogWarning("Command line arguments are invalid. Key: {key}", key);
i -= 1;
continue;
}
key = key.Trim().TrimStart('-').TrimStart('-').ToLower();
_argDict.Add(key, args[i + 1].Trim());
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while processing args.");
}
}
}
}

View File

@ -1,9 +1,9 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Shared;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Entities;
using Remotely.Shared.Primitives;
using Remotely.Shared.Services;
using System.Diagnostics;
using System.Net.Http.Json;
@ -16,7 +16,7 @@ public class BrandingProvider : IBrandingProvider
private readonly IEmbeddedServerDataProvider _embeddedDataSearcher;
private readonly ILogger<BrandingProvider> _logger;
private readonly IOrganizationIdProvider _orgIdProvider;
private BrandingInfoBase? _brandingInfo;
private BrandingInfo? _brandingInfo;
public BrandingProvider(
@ -31,7 +31,7 @@ public class BrandingProvider : IBrandingProvider
_logger = logger;
}
public BrandingInfoBase CurrentBranding => _brandingInfo ??
public BrandingInfo CurrentBranding => _brandingInfo ??
throw new InvalidOperationException("Branding info has not been set or initialized.");
public async Task Initialize()
@ -66,7 +66,7 @@ public class BrandingProvider : IBrandingProvider
}
}
public void SetBrandingInfo(BrandingInfoBase brandingInfo)
public void SetBrandingInfo(BrandingInfo brandingInfo)
{
_brandingInfo = brandingInfo;
}

View File

@ -0,0 +1,88 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Models;
using System.IO.Pipes;
using System.Text.Json;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IChatHostService
{
Task StartChat(string requesterID, string organizationName);
}
public class ChatHostService : IChatHostService
{
private readonly IChatUiService _chatUiService;
private readonly ILogger<ChatHostService> _logger;
private NamedPipeServerStream? _namedPipeStream;
private StreamReader? _reader;
private StreamWriter? _writer;
public ChatHostService(IChatUiService chatUiService, ILogger<ChatHostService> logger)
{
_chatUiService = chatUiService;
_logger = logger;
}
public async Task StartChat(string pipeName, string organizationName)
{
_namedPipeStream = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 10, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
_writer = new StreamWriter(_namedPipeStream);
_reader = new StreamReader(_namedPipeStream);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
_logger.LogInformation("Waiting for chat client to connect via pipe {name}.", pipeName);
await _namedPipeStream.WaitForConnectionAsync(cts.Token);
}
catch (OperationCanceledException)
{
_logger.LogWarning("A chat session was attempted, but the client failed to connect in time.");
Environment.Exit(0);
}
_logger.LogInformation("Chat client connected.");
_chatUiService.ChatWindowClosed += OnChatWindowClosed;
_chatUiService.ShowChatWindow(organizationName, _writer);
_ = Task.Run(ReadFromStream);
}
private void OnChatWindowClosed(object? sender, EventArgs e)
{
try
{
_namedPipeStream?.Dispose();
}
catch { }
}
private async Task ReadFromStream()
{
while (_namedPipeStream?.IsConnected == true)
{
try
{
var messageJson = await _reader!.ReadLineAsync();
if (!string.IsNullOrWhiteSpace(messageJson))
{
var chatMessage = JsonSerializer.Deserialize<ChatMessage>(messageJson);
if (chatMessage is null)
{
_logger.LogWarning("Deserialized message was null. Value: {value}", messageJson);
continue;
}
await _chatUiService.ReceiveChat(chatMessage);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while reading from chat IPC stream.");
}
}
}
}

View File

@ -0,0 +1,43 @@
using Immense.RemoteControl.Desktop.Shared.Native.Linux;
using System.Security.Principal;
namespace Desktop.Shared.Services;
public interface IDesktopEnvironment
{
bool IsElevated { get; }
bool IsDebug { get; }
}
internal class DesktopEnvironment : IDesktopEnvironment
{
public bool IsDebug
{
get
{
#if DEBUG
return true;
#else
return false;
#endif
}
}
public bool IsElevated
{
get
{
if (OperatingSystem.IsWindows())
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
if (OperatingSystem.IsLinux())
{
return Libc.geteuid() == 0;
}
return false;
}
}
}

View File

@ -0,0 +1,461 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Messages;
using Immense.RemoteControl.Desktop.Shared.Native.Windows;
using Immense.RemoteControl.Shared.Enums;
using Immense.RemoteControl.Shared.Interfaces;
using Immense.RemoteControl.Shared.Models;
using Bitbound.SimpleMessenger;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Primitives;
using System.Diagnostics;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IDesktopHubConnection
{
HubConnection? Connection { get; }
HubConnectionState ConnectionState { get; }
bool IsConnected { get; }
Task<Result<TimeSpan>> CheckRoundtripLatency(string viewerConnectionId);
Task<bool> Connect(TimeSpan timeout, CancellationToken cancellationToken);
Task Disconnect();
Task DisconnectAllViewers();
Task DisconnectViewer(IViewer viewer, bool notifyViewer);
Task<string> GetSessionID();
Task NotifyRequesterUnattendedReady();
Task NotifyViewersRelaunchedScreenCasterReady(string[] viewerIDs);
Task SendAttendedSessionInfo(string machineName);
Task SendConnectionFailedToViewers(List<string> viewerIDs);
Task SendConnectionRequestDenied(string viewerID);
Task SendDtoToViewer<T>(T dto, string viewerId);
Task SendMessageToViewer(string viewerID, string message);
Task<Result> SendUnattendedSessionInfo(string sessionId, string accessKey, string machineName, string requesterName, string organizationName);
}
public class DesktopHubConnection : IDesktopHubConnection, IDesktopHubClient
{
private readonly IAppState _appState;
private readonly ILogger<DesktopHubConnection> _logger;
private readonly IDtoMessageHandler _messageHandler;
private readonly IRemoteControlAccessService _remoteControlAccessService;
private readonly IServiceProvider _serviceProvider;
public DesktopHubConnection(
IDtoMessageHandler messageHandler,
IServiceProvider serviceProvider,
IAppState appState,
IRemoteControlAccessService remoteControlAccessService,
IMessenger messenger,
ILogger<DesktopHubConnection> logger)
{
_messageHandler = messageHandler;
_remoteControlAccessService = remoteControlAccessService;
_serviceProvider = serviceProvider;
_appState = appState;
_logger = logger;
messenger.Register<WindowsSessionEndingMessage>(this, HandleWindowsSessionEnding);
messenger.Register<WindowsSessionSwitchedMessage>(this, HandleWindowsSessionChanged);
}
public HubConnection? Connection { get; private set; }
public HubConnectionState ConnectionState => Connection?.State ?? HubConnectionState.Disconnected;
public bool IsConnected => Connection?.State == HubConnectionState.Connected;
public async Task<Result<TimeSpan>> CheckRoundtripLatency(string viewerConnectionId)
{
try
{
if (Connection is null)
{
return Result.Fail<TimeSpan>("Connection is not yet established.");
}
var sw = Stopwatch.StartNew();
var result = await Connection.InvokeAsync<Result<string>>("PingViewer", viewerConnectionId);
if (result.IsSuccess)
{
return Result.Ok(sw.Elapsed);
}
return Result.Fail<TimeSpan>("Latency check failed.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check latency.");
return Result.Fail<TimeSpan>("An error occurred while checking latency.");
}
}
public async Task<bool> Connect(TimeSpan timeout, CancellationToken cancellationToken)
{
try
{
if (Connection is not null)
{
await Connection.DisposeAsync();
}
var result = BuildConnection();
if (!result.IsSuccess)
{
return false;
}
Connection = result.Value;
ApplyConnectionHandlers(Connection);
var sw = Stopwatch.StartNew();
while (!cancellationToken.IsCancellationRequested)
{
try
{
_logger.LogInformation("Connecting to server.");
await Connection.StartAsync(cancellationToken);
_logger.LogInformation("Connected to server.");
break;
}
catch (HttpRequestException ex)
{
_logger.LogWarning("Failed to connect to server. Status Code: {code}", ex.StatusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in hub connection.");
}
await Task.Delay(3_000, cancellationToken);
if (sw.Elapsed > timeout)
{
_logger.LogWarning("Timed out while trying to connect to desktop hub.");
return false;
}
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while connecting to hub.");
return false;
}
}
public async Task Disconnect()
{
try
{
if (Connection is not null)
{
await Connection.StopAsync();
await Connection.DisposeAsync();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disconnecting websocket.");
}
}
public async Task Disconnect(string reason)
{
_logger.LogInformation("Disconnecting caster socket. Reason: {reason}", reason);
await DisconnectAllViewers();
}
public async Task DisconnectAllViewers()
{
foreach (var viewer in _appState.Viewers.Values.ToList())
{
await DisconnectViewer(viewer, true);
}
}
public Task DisconnectViewer(IViewer viewer, bool notifyViewer)
{
if (Connection is null)
{
return Task.CompletedTask;
}
viewer.DisconnectRequested = true;
viewer.Dispose();
return Connection.SendAsync("DisconnectViewer", viewer.ViewerConnectionId, notifyViewer);
}
public Task GetScreenCast(
string viewerId,
string requesterName,
bool notifyUser,
Guid streamId)
{
// We don't want to tie up the invocation from the server, so we'll
// start this in a new task.
_ = Task.Run(async () =>
{
try
{
using var screenCaster = _serviceProvider.GetRequiredService<IScreenCaster>();
await screenCaster.BeginScreenCasting(
new ScreenCastRequest()
{
NotifyUser = notifyUser,
ViewerId = viewerId,
RequesterName = requesterName,
StreamId = streamId
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while casting screen.");
}
});
return Task.CompletedTask;
}
public async Task<string> GetSessionID()
{
if (Connection is null)
{
return string.Empty;
}
return await Connection.InvokeAsync<string>("GetSessionID");
}
public Task NotifyRequesterUnattendedReady()
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.SendAsync("NotifyRequesterUnattendedReady");
}
public Task NotifyViewersRelaunchedScreenCasterReady(string[] viewerIDs)
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.SendAsync("NotifyViewersRelaunchedScreenCasterReady", viewerIDs);
}
public async Task<PromptForAccessResult> PromptForAccess(RemoteControlAccessRequest accessRequest)
{
try
{
// TODO: Add this to Win32Interop service/interface when it's
// extracted from current static class.
if (OperatingSystem.IsWindows() &&
Shlwapi.IsOS(OsType.OS_ANYSERVER) &&
Process.GetCurrentProcess().SessionId == Kernel32.WTSGetActiveConsoleSessionId())
{
// Bypass "consent prompt" if we're targeting the console session
// on a Windows Server OS.
return PromptForAccessResult.Accepted;
}
await SendMessageToViewer(accessRequest.ViewerConnectionId, "Asking user for permission");
return await _remoteControlAccessService.PromptForAccess(
accessRequest.RequesterDisplayName,
accessRequest.OrganizationName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while applying connection handlers.");
return PromptForAccessResult.Error;
}
}
public Task RequestScreenCast(string viewerId, string requesterName, bool notifyUser, Guid streamId)
{
_appState.InvokeScreenCastRequested(new ScreenCastRequest()
{
NotifyUser = notifyUser,
ViewerId = viewerId,
RequesterName = requesterName,
StreamId = streamId
});
return Task.CompletedTask;
}
public Task SendAttendedSessionInfo(string machineName)
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.InvokeAsync("ReceiveAttendedSessionInfo", machineName);
}
public Task SendConnectionFailedToViewers(List<string> viewerIDs)
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.SendAsync("SendConnectionFailedToViewers", viewerIDs);
}
public Task SendConnectionRequestDenied(string viewerID)
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.SendAsync("SendConnectionRequestDenied", viewerID);
}
public async Task SendDtoToClient(byte[] dtoWrapper, string viewerConnectionId)
{
if (_appState.Viewers.TryGetValue(viewerConnectionId, out var viewer))
{
await _messageHandler.ParseMessage(viewer, dtoWrapper);
}
}
public Task SendDtoToViewer<T>(T dto, string viewerId)
{
if (Connection is null)
{
return Task.CompletedTask;
}
var serializedDto = MessagePack.MessagePackSerializer.Serialize(dto);
return Connection.SendAsync("SendDtoToViewer", serializedDto, viewerId);
}
public Task SendMessageToViewer(string viewerID, string message)
{
if (Connection is null)
{
return Task.CompletedTask;
}
return Connection.SendAsync("SendMessageToViewer", viewerID, message);
}
public async Task<Result> SendUnattendedSessionInfo(string unattendedSessionId, string accessKey, string machineName, string requesterName, string organizationName)
{
if (Connection is null)
{
return Result.Fail("Connection hasn't been made yet.");
}
return await Connection.InvokeAsync<Result>("ReceiveUnattendedSessionInfo", unattendedSessionId, accessKey, machineName, requesterName, organizationName);
}
public async Task ViewerDisconnected(string viewerId)
{
if (Connection is null)
{
return;
}
await Connection.SendAsync("DisconnectViewer", viewerId, false);
if (_appState.Viewers.TryRemove(viewerId, out var viewer))
{
viewer.DisconnectRequested = true;
viewer.Dispose();
}
_appState.InvokeViewerRemoved(viewerId);
}
private void ApplyConnectionHandlers(HubConnection connection)
{
connection.Closed += (ex) =>
{
_logger.LogWarning(ex, "Connection closed.");
return Task.CompletedTask;
};
// TODO: Replace parameters with singular DTOs for both client and server methods.
connection.On<string>(nameof(Disconnect), Disconnect);
connection.On<string, string, bool, Guid>(nameof(GetScreenCast), GetScreenCast);
connection.On<string, string, bool, Guid>(nameof(RequestScreenCast), RequestScreenCast);
connection.On<byte[], string>(nameof(SendDtoToClient), SendDtoToClient);
connection.On<string>(nameof(ViewerDisconnected), ViewerDisconnected);
connection.On<RemoteControlAccessRequest, PromptForAccessResult>(nameof(PromptForAccess), PromptForAccess);
}
private Result<HubConnection> BuildConnection()
{
try
{
if (!Uri.TryCreate(_appState.Host, UriKind.Absolute, out _))
{
return Result.Fail<HubConnection>("Invalid server URI.");
}
var builder = _serviceProvider.GetRequiredService<IHubConnectionBuilder>();
var connection = builder
.WithUrl($"{_appState.Host.Trim().TrimEnd('/')}/hubs/desktop")
.AddMessagePackProtocol()
.WithAutomaticReconnect(new RetryPolicy())
.Build();
return Result.Ok(connection);
}
catch (Exception ex)
{
return Result.Fail<HubConnection>(ex);
}
}
private async Task HandleWindowsSessionChanged(object subscriber, WindowsSessionSwitchedMessage message)
{
try
{
if (Connection is null)
{
return;
}
await Connection.SendAsync("NotifySessionChanged", message.Reason, message.SessionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while notifying of session change.");
}
}
private async Task HandleWindowsSessionEnding(object subscriber, WindowsSessionEndingMessage message)
{
try
{
if (Connection is null)
{
return;
}
await Connection.SendAsync("NotifySessionEnding", message.Reason);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while notifying of session ending.");
}
}
private class RetryPolicy : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
return TimeSpan.FromSeconds(3);
}
}
}

View File

@ -0,0 +1,331 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Native.Windows;
using Immense.RemoteControl.Shared.Helpers;
using Immense.RemoteControl.Shared.Models.Dtos;
using MessagePack;
using Microsoft.Extensions.Logging;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IDtoMessageHandler
{
Task ParseMessage(IViewer viewer, byte[] message);
}
public class DtoMessageHandler : IDtoMessageHandler
{
private readonly IAudioCapturer _audioCapturer;
private readonly IClipboardService _clipboardService;
private readonly IFileTransferService _fileTransferService;
private readonly IKeyboardMouseInput _keyboardMouseInput;
private readonly ILogger<DtoMessageHandler> _logger;
public DtoMessageHandler(
IKeyboardMouseInput keyboardMouseInput,
IAudioCapturer audioCapturer,
IClipboardService clipboardService,
IFileTransferService fileTransferService,
ILogger<DtoMessageHandler> logger)
{
_keyboardMouseInput = keyboardMouseInput;
_audioCapturer = audioCapturer;
_clipboardService = clipboardService;
_fileTransferService = fileTransferService;
_logger = logger;
}
public async Task ParseMessage(IViewer viewer, byte[] message)
{
try
{
var wrapper = MessagePackSerializer.Deserialize<DtoWrapper>(message);
switch (wrapper.DtoType)
{
case DtoType.MouseMove:
case DtoType.MouseDown:
case DtoType.MouseUp:
case DtoType.Tap:
case DtoType.MouseWheel:
case DtoType.KeyDown:
case DtoType.KeyUp:
case DtoType.CtrlAltDel:
case DtoType.ToggleBlockInput:
case DtoType.TextTransfer:
case DtoType.KeyPress:
case DtoType.SetKeyStatesUp:
{
if (!viewer.HasControl)
{
return;
}
}
break;
default:
break;
}
switch (wrapper.DtoType)
{
case DtoType.SelectScreen:
SelectScreen(wrapper, viewer);
break;
case DtoType.MouseMove:
MouseMove(wrapper, viewer);
break;
case DtoType.MouseDown:
MouseDown(wrapper, viewer);
break;
case DtoType.MouseUp:
MouseUp(wrapper, viewer);
break;
case DtoType.Tap:
Tap(wrapper, viewer);
break;
case DtoType.MouseWheel:
MouseWheel(wrapper);
break;
case DtoType.KeyDown:
KeyDown(wrapper);
break;
case DtoType.KeyUp:
KeyUp(wrapper);
break;
case DtoType.CtrlAltDel:
CtrlAltDel();
break;
case DtoType.ToggleAudio:
ToggleAudio(wrapper);
break;
case DtoType.ToggleBlockInput:
ToggleBlockInput(wrapper);
break;
case DtoType.TextTransfer:
await TransferText(wrapper);
break;
case DtoType.KeyPress:
await KeyPress(wrapper);
break;
case DtoType.File:
await DownloadFile(wrapper);
break;
case DtoType.WindowsSessions:
await GetWindowsSessions(viewer);
break;
case DtoType.SetKeyStatesUp:
SetKeyStatesUp();
break;
case DtoType.FrameReceived:
HandleFrameReceived(wrapper, viewer);
break;
case DtoType.OpenFileTransferWindow:
OpenFileTransferWindow(viewer);
break;
default:
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while parsing message.");
}
}
private async Task TransferText(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<TextTransferDto>(wrapper, out var dto))
{
return;
}
if (dto!.TypeText)
{
_keyboardMouseInput.SendText(dto.Text);
}
else
{
await _clipboardService.SetText(dto.Text);
}
}
private void CtrlAltDel()
{
if (OperatingSystem.IsWindows())
{
// Might as well try both.
User32.SendSAS(AsUser: false);
User32.SendSAS(true);
}
}
private async Task DownloadFile(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<FileDto>(wrapper, out var dto))
{
return;
}
await _fileTransferService.ReceiveFile(dto!.Buffer,
dto.FileName,
dto.MessageId,
dto.EndOfFile,
dto.StartOfFile);
}
private async Task GetWindowsSessions(IViewer viewer)
{
await viewer.SendWindowsSessions();
}
private void HandleFrameReceived(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<FrameReceivedDto>(wrapper, out var dto))
{
return;
}
var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(dto.Timestamp);
viewer.SetLastFrameReceived(timestamp.ToLocalTime());
}
private void KeyDown(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<KeyDownDto>(wrapper, out var dto))
{
return;
}
if (dto?.Key is null)
{
_logger.LogWarning("Key input is empty.");
return;
}
_keyboardMouseInput.SendKeyDown(dto.Key);
}
private async Task KeyPress(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<KeyPressDto>(wrapper, out var dto))
{
return;
}
if (dto?.Key is null)
{
_logger.LogWarning("Key input is empty.");
return;
}
_keyboardMouseInput.SendKeyDown(dto.Key);
await Task.Delay(1);
_keyboardMouseInput.SendKeyUp(dto.Key);
}
private void KeyUp(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<KeyUpDto>(wrapper, out var dto))
{
return;
}
if (dto?.Key is null)
{
_logger.LogWarning("Key input is empty.");
return;
}
_keyboardMouseInput.SendKeyUp(dto.Key);
}
private void MouseDown(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<MouseDownDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.SendMouseButtonAction(dto!.Button, ButtonAction.Down, dto.PercentX, dto.PercentY, viewer);
}
private void MouseMove(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<MouseMoveDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.SendMouseMove(dto!.PercentX, dto.PercentY, viewer);
}
private void MouseUp(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<MouseUpDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.SendMouseButtonAction(dto!.Button, ButtonAction.Up, dto.PercentX, dto.PercentY, viewer);
}
private void MouseWheel(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<MouseWheelDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.SendMouseWheel(-(int)dto!.DeltaY);
}
private void OpenFileTransferWindow(IViewer viewer)
{
_fileTransferService.OpenFileTransferWindow(viewer);
}
private void SelectScreen(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<SelectScreenDto>(wrapper, out var dto))
{
return;
}
viewer.Capturer.SetSelectedScreen(dto!.DisplayName);
}
private void SetKeyStatesUp()
{
_keyboardMouseInput.SetKeyStatesUp();
}
private void Tap(DtoWrapper wrapper, IViewer viewer)
{
if (!DtoChunker.TryComplete<TapDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.SendMouseButtonAction(0, ButtonAction.Down, dto!.PercentX, dto.PercentY, viewer);
_keyboardMouseInput.SendMouseButtonAction(0, ButtonAction.Up, dto.PercentX, dto.PercentY, viewer);
}
private void ToggleAudio(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<ToggleAudioDto>(wrapper, out var dto))
{
return;
}
_audioCapturer.ToggleAudio(dto!.ToggleOn);
}
private void ToggleBlockInput(DtoWrapper wrapper)
{
if (!DtoChunker.TryComplete<ToggleBlockInputDto>(wrapper, out var dto))
{
return;
}
_keyboardMouseInput.ToggleBlockInput(dto!.ToggleOn);
}
}

View File

@ -0,0 +1,96 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using System.Timers;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IIdleTimer
{
DateTimeOffset ViewersLastSeen { get; }
void Start();
void Stop();
}
public class IdleTimer : IIdleTimer
{
private readonly IAppState _appState;
private readonly IRemoteControlAccessService _accessService;
private readonly IDesktopHubConnection _desktopHubConnection;
private readonly IShutdownService _shutdownService;
private readonly ILogger<IdleTimer> _logger;
private readonly SemaphoreSlim _elapseLock = new(1, 1);
private System.Timers.Timer? _timer;
public IdleTimer(
IAppState appState,
IRemoteControlAccessService accessService,
IDesktopHubConnection desktopHubConnection,
IShutdownService shutdownService,
ILogger<IdleTimer> logger)
{
_appState = appState;
_accessService = accessService;
_desktopHubConnection = desktopHubConnection;
_shutdownService = shutdownService;
_logger = logger;
}
public DateTimeOffset ViewersLastSeen { get; private set; } = DateTimeOffset.Now;
public void Start()
{
_logger.LogInformation("Starting idle timer.");
_timer?.Dispose();
_timer = new System.Timers.Timer(100);
_timer.Elapsed += Timer_Elapsed;
_timer.Start();
}
public void Stop()
{
_timer?.Stop();
_timer?.Dispose();
}
private async void Timer_Elapsed(object? sender, ElapsedEventArgs e)
{
if (!await _elapseLock.WaitAsync(0))
{
return;
}
try
{
if (_appState.Mode == Enums.AppMode.Unattended &&
!_desktopHubConnection.IsConnected)
{
_logger.LogWarning(
"App is in unattended mode and is disconnected " +
"from the server. Shutting down.");
await _shutdownService.Shutdown();
return;
}
if (!_appState.Viewers.IsEmpty ||
_accessService.IsPromptOpen)
{
ViewersLastSeen = DateTimeOffset.Now;
return;
}
if (DateTimeOffset.Now - ViewersLastSeen > TimeSpan.FromSeconds(30))
{
_logger.LogWarning("No viewers connected for 30 seconds. Shutting down.");
await _shutdownService.Shutdown();
}
}
finally
{
_elapseLock.Release();
}
}
}

View File

@ -0,0 +1,212 @@
using Immense.RemoteControl.Desktop.Shared.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Remotely.Shared.Primitives;
using SkiaSharp;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IImageHelper
{
SKBitmap CropBitmap(SKBitmap bitmap, SKRect cropArea);
byte[] EncodeBitmap(SKBitmap bitmap, SKEncodedImageFormat format, int quality);
SKRect GetDiffArea(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false);
Result<SKBitmap> GetImageDiff(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false);
}
public class ImageHelper : IImageHelper
{
private static readonly RecyclableMemoryStreamManager _recycleManager = new();
private readonly ILogger<ImageHelper> _logger;
public ImageHelper(ILogger<ImageHelper> logger)
{
_logger = logger;
}
public byte[] EncodeBitmap(SKBitmap bitmap, SKEncodedImageFormat format, int quality)
{
using var ms = _recycleManager.GetStream();
bitmap.Encode(ms, format, quality);
return ms.ToArray();
}
public SKBitmap CropBitmap(SKBitmap bitmap, SKRect cropArea)
{
var cropped = new SKBitmap((int)cropArea.Width, (int)cropArea.Height);
using var canvas = new SKCanvas(cropped);
canvas.DrawBitmap(
bitmap,
cropArea,
new SKRect(0, 0, cropArea.Width, cropArea.Height));
return cropped;
}
public Result<SKBitmap> GetImageDiff(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false)
{
try
{
if (currentFrame is null)
{
return Result.Fail<SKBitmap>("Current frame cannot be null.");
}
if (previousFrame is null || forceFullscreen)
{
return Result.Ok(currentFrame.Copy());
}
if (currentFrame.Height != previousFrame.Height ||
currentFrame.Width != previousFrame.Width ||
currentFrame.BytesPerPixel != previousFrame.BytesPerPixel)
{
return Result.Fail<SKBitmap>("Frames are not of equal size.");
}
var width = currentFrame.Width;
var height = currentFrame.Height;
var anyChanges = false;
var diffFrame = new SKBitmap(width, height);
var bytesPerPixel = currentFrame.BytesPerPixel;
var totalSize = currentFrame.ByteCount;
unsafe
{
byte* scan1 = (byte*)currentFrame.GetPixels().ToPointer();
byte* scan2 = (byte*)previousFrame.GetPixels().ToPointer();
byte* scan3 = (byte*)diffFrame.GetPixels().ToPointer();
for (var row = 0; row < height; row++)
{
for (var column = 0; column < width; column++)
{
var index = (row * width * bytesPerPixel) + (column * bytesPerPixel);
byte* data1 = scan1 + index;
byte* data2 = scan2 + index;
byte* data3 = scan3 + index;
if (data1[0] != data2[0] ||
data1[1] != data2[1] ||
data1[2] != data2[2] ||
data1[3] != data2[3])
{
anyChanges = true;
data3[0] = data2[0];
data3[1] = data2[1];
data3[2] = data2[2];
data3[3] = data2[3];
}
}
}
}
if (anyChanges)
{
return Result.Ok(diffFrame);
}
diffFrame.Dispose();
return Result.Fail<SKBitmap>("No difference found.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while getting image diff.");
return Result.Fail<SKBitmap>(ex);
}
}
public SKRect GetDiffArea(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false)
{
try
{
if (currentFrame is null)
{
return SKRect.Empty;
}
if (previousFrame is null || forceFullscreen)
{
return currentFrame.ToRectangle();
}
if (currentFrame.Height != previousFrame.Height ||
currentFrame.Width != previousFrame.Width ||
currentFrame.BytesPerPixel != previousFrame.BytesPerPixel)
{
return SKRect.Empty;
}
var width = currentFrame.Width;
var height = currentFrame.Height;
int left = int.MaxValue;
int top = int.MaxValue;
int right = int.MinValue;
int bottom = int.MinValue;
var bytesPerPixel = currentFrame.BytesPerPixel;
var totalSize = currentFrame.ByteCount;
unsafe
{
byte* scan1 = (byte*)currentFrame.GetPixels().ToPointer();
byte* scan2 = (byte*)previousFrame.GetPixels().ToPointer();
for (var row = 0; row < height; row++)
{
for (var column = 0; column < width; column++)
{
var index = (row * width * bytesPerPixel) + (column * bytesPerPixel);
byte* data1 = scan1 + index;
byte* data2 = scan2 + index;
if (data1[0] != data2[0] ||
data1[1] != data2[1] ||
data1[2] != data2[2])
{
if (row < top)
{
top = row;
}
if (row > bottom)
{
bottom = row;
}
if (column < left)
{
left = column;
}
if (column > right)
{
right = column;
}
}
}
}
// Check for valid bounding box.
if (left <= right && top <= bottom)
{
left = Math.Max(left - 2, 0);
top = Math.Max(top - 2, 0);
right = Math.Min(right + 2, width);
bottom = Math.Min(bottom + 2, height);
return new SKRect(left, top, right, bottom);
}
return SKRect.Empty;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while getting area diff.");
return SKRect.Empty;
}
}
}

View File

@ -0,0 +1,283 @@
using Microsoft.Extensions.DependencyInjection;
using SkiaSharp;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
using Immense.RemoteControl.Shared.Helpers;
using Immense.RemoteControl.Shared.Models.Dtos;
using MessagePack;
using Immense.RemoteControl.Shared.Services;
using Microsoft.IO;
using System.Diagnostics;
using Bitbound.SimpleMessenger;
using Immense.RemoteControl.Desktop.Shared.Messages;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IScreenCaster : IDisposable
{
Task BeginScreenCasting(ScreenCastRequest screenCastRequest);
}
internal class ScreenCaster : IScreenCaster
{
private readonly IAppState _appState;
private readonly ICursorIconWatcher _cursorIconWatcher;
private readonly IImageHelper _imageHelper;
private readonly ILogger<ScreenCaster> _logger;
private readonly CancellationTokenSource _metricsCts = new();
private readonly RecyclableMemoryStreamManager _recycleStreams = new();
private readonly ISessionIndicator _sessionIndicator;
private readonly IShutdownService _shutdownService;
private readonly ISystemTime _systemTime;
private readonly IViewerFactory _viewerFactory;
private readonly IDisposable[] _messengerRegistrations;
private bool _isWindowsSessionEnding;
public ScreenCaster(
IAppState appState,
IViewerFactory viewerFactory,
ICursorIconWatcher cursorIconWatcher,
ISessionIndicator sessionIndicator,
IShutdownService shutdownService,
IImageHelper imageHelper,
ISystemTime systemTime,
IMessenger messenger,
ILogger<ScreenCaster> logger)
{
_appState = appState;
_cursorIconWatcher = cursorIconWatcher;
_sessionIndicator = sessionIndicator;
_shutdownService = shutdownService;
_imageHelper = imageHelper;
_systemTime = systemTime;
_viewerFactory = viewerFactory;
_logger = logger;
_messengerRegistrations =
[
messenger.Register<WindowsSessionSwitchedMessage>(this, HandleWindowsSessionSwitchedMessage),
messenger.Register<WindowsSessionEndingMessage>(this, HandleWindowsSessionEndingMessage)
];
}
public async Task BeginScreenCasting(ScreenCastRequest screenCastRequest)
{
await BeginScreenCastingImpl(screenCastRequest).ConfigureAwait(false);
}
public void Dispose()
{
foreach (var registration in _messengerRegistrations)
{
try
{
registration.Dispose();
}
catch { }
}
_metricsCts.Cancel();
_metricsCts.Dispose();
GC.SuppressFinalize(this);
}
private async Task BeginScreenCastingImpl(ScreenCastRequest screenCastRequest)
{
using var viewer = _viewerFactory.CreateViewer(screenCastRequest.RequesterName, screenCastRequest.ViewerId);
try
{
viewer.Name = screenCastRequest.RequesterName;
viewer.ViewerConnectionId = screenCastRequest.ViewerId;
var screenBounds = viewer.Capturer.CurrentScreenBounds;
_logger.LogInformation(
"Starting screen cast. Requester: {viewerName}. " +
"Viewer ID: {viewerViewerConnectionID}. App Mode: {mode}",
viewer.Name,
viewer.ViewerConnectionId,
_appState.Mode);
_appState.Viewers.AddOrUpdate(viewer.ViewerConnectionId, viewer, (id, v) => viewer);
if (_appState.Mode == AppMode.Attended)
{
_appState.InvokeViewerAdded(viewer);
}
if (_appState.Mode == AppMode.Unattended && screenCastRequest.NotifyUser)
{
_sessionIndicator.Show();
}
await viewer.SendScreenData(
viewer.Capturer.SelectedScreen,
viewer.Capturer.GetDisplayNames(),
screenBounds.Width,
screenBounds.Height);
await viewer.SendCursorChange(_cursorIconWatcher.GetCurrentCursor());
await viewer.SendWindowsSessions();
viewer.Capturer.ScreenChanged += async (sender, bounds) =>
{
await viewer.SendScreenSize(bounds.Width, bounds.Height);
};
_ = Task.Run(() => LogMetrics(viewer, _metricsCts.Token));
using var sessionEndSignal = new SemaphoreSlim(0, 1);
await viewer.SendDesktopStream(GetDesktopStream(viewer, sessionEndSignal), screenCastRequest.StreamId);
if (!await sessionEndSignal.WaitAsync(TimeSpan.FromHours(8)))
{
_logger.LogWarning("Timed out while waiting for session to end.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while starting screen casting.");
}
finally
{
_logger.LogInformation(
"Ended desktop stream. " +
"Requester: {viewerName}. " +
"Viewer ID: {viewerConnectionID}. " +
"Viewer Responsive: {isResponsive}. " +
"Viewer Disconnected Requested: {viewerDisconnectRequested}. " +
"Windows Session Ending: {windowsSessionEnding}",
viewer.Name,
viewer.ViewerConnectionId,
viewer.IsResponsive,
viewer.DisconnectRequested,
_isWindowsSessionEnding);
_appState.Viewers.TryRemove(viewer.ViewerConnectionId, out _);
Disposer.TryDisposeAll(viewer);
// Close if no one is viewing.
if (_appState.Viewers.IsEmpty && _appState.Mode == AppMode.Unattended)
{
_logger.LogInformation("No more viewers. Calling shutdown service.");
await _shutdownService.Shutdown();
}
}
}
private async IAsyncEnumerable<byte[]> GetDesktopStream(IViewer viewer, SemaphoreSlim sessionEndedSignal)
{
await Task.Yield();
try
{
while (!viewer.DisconnectRequested && viewer.IsResponsive && !_isWindowsSessionEnding)
{
viewer.IncrementFpsCount();
await viewer.ApplyAutoQuality();
if (!await viewer.WaitForViewer())
{
_logger.LogWarning(
"Viewer is behind on frames and did not catch up in time.");
}
var result = viewer.Capturer.GetNextFrame();
if (!result.IsSuccess)
{
await Task.Yield();
continue;
}
var diffArea = viewer.Capturer.GetFrameDiffArea();
if (diffArea.IsEmpty)
{
await Task.Yield();
continue;
}
viewer.Capturer.CaptureFullscreen = false;
using var croppedFrame = _imageHelper.CropBitmap(result.Value, diffArea);
var encodedImageBytes = _imageHelper.EncodeBitmap(croppedFrame, SKEncodedImageFormat.Jpeg, viewer.ImageQuality);
if (encodedImageBytes.Length == 0)
{
continue;
}
viewer.AppendSentFrame(new SentFrame(encodedImageBytes.Length, _systemTime.Now));
using var frameStream = _recycleStreams.GetStream();
using var writer = new BinaryWriter(frameStream);
writer.Write(encodedImageBytes.Length);
writer.Write(diffArea.Left);
writer.Write(diffArea.Top);
writer.Write(diffArea.Width);
writer.Write(diffArea.Height);
writer.Write(DateTimeOffset.Now.ToUnixTimeMilliseconds());
writer.Write(encodedImageBytes);
frameStream.Seek(0, SeekOrigin.Begin);
foreach (var chunk in frameStream.ToArray().Chunk(50_000))
{
yield return chunk;
}
}
}
finally
{
sessionEndedSignal.Release();
}
}
private Task HandleWindowsSessionEndingMessage(object subscriber, WindowsSessionEndingMessage arg)
{
_logger.LogInformation("Windows session ending. Stopping screen cast.");
_isWindowsSessionEnding = true;
return Task.CompletedTask;
}
private Task HandleWindowsSessionSwitchedMessage(object subscriber, WindowsSessionSwitchedMessage arg)
{
_logger.LogInformation("Windows session switched. Stopping screen cast.");
_isWindowsSessionEnding = true;
return Task.CompletedTask;
}
private async Task LogMetrics(IViewer viewer, CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await viewer.CalculateMetrics();
var metrics = new SessionMetricsDto(
Math.Round(viewer.CurrentMbps, 2),
viewer.CurrentFps,
viewer.RoundTripLatency.TotalMilliseconds,
viewer.Capturer.IsGpuAccelerated);
_logger.LogDebug(
"Current Mbps: {currentMbps}. " +
"Current FPS: {currentFps}. " +
"Roundtrip Latency: {roundTripLatency}ms. " +
"Image Quality: {imageQuality}",
metrics.Mbps,
metrics.Fps,
metrics.RoundTripLatency,
viewer.ImageQuality);
await viewer.SendSessionMetrics(metrics);
}
}
}

View File

@ -0,0 +1,374 @@
using System.Collections.Concurrent;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
using Immense.RemoteControl.Shared.Helpers;
using Immense.RemoteControl.Shared.Models.Dtos;
using Immense.RemoteControl.Desktop.Shared.ViewModels;
using Microsoft.AspNetCore.SignalR.Client;
using Immense.RemoteControl.Shared.Services;
using Immense.RemoteControl.Desktop.Shared.Native.Windows;
namespace Immense.RemoteControl.Desktop.Shared.Services;
public interface IViewer : IDisposable
{
IScreenCapturer Capturer { get; }
double CurrentFps { get; }
double CurrentMbps { get; }
bool DisconnectRequested { get; set; }
bool HasControl { get; set; }
int ImageQuality { get; }
bool IsResponsive { get; }
string Name { get; set; }
TimeSpan RoundTripLatency { get; }
string ViewerConnectionId { get; set; }
void AppendSentFrame(SentFrame sentFrame);
Task ApplyAutoQuality();
Task CalculateMetrics();
void IncrementFpsCount();
Task SendAudioSample(byte[] audioSample);
Task SendClipboardText(string clipboardText);
Task SendCursorChange(CursorInfo cursorInfo);
Task SendDesktopStream(IAsyncEnumerable<byte[]> asyncEnumerable, Guid streamId);
Task SendFile(FileUpload fileUpload, Action<double> progressUpdateCallback, CancellationToken cancelToken);
Task SendScreenData(string selectedDisplay, IEnumerable<string> displayNames, int screenWidth, int screenHeight);
Task SendScreenSize(int width, int height);
Task SendSessionMetrics(SessionMetricsDto metrics);
Task SendWindowsSessions();
void SetLastFrameReceived(DateTimeOffset timestamp);
Task<bool> WaitForViewer();
}
public class Viewer : IViewer
{
public const int DefaultQuality = 80;
private readonly IAudioCapturer _audioCapturer;
private readonly IClipboardService _clipboardService;
private readonly IDesktopHubConnection _desktopHubConnection;
private readonly ConcurrentQueue<DateTimeOffset> _fpsQueue = new();
private readonly ILogger<Viewer> _logger;
private readonly ConcurrentQueue<SentFrame> _sentFrames = new();
private readonly ISystemTime _systemTime;
private bool _disconnectRequested;
private volatile int _framesSentSinceLastReceipt;
private DateTimeOffset _lastFrameReceived = DateTimeOffset.Now;
private DateTimeOffset _lastFrameSent = DateTimeOffset.Now;
private int _pingFailures;
public Viewer(
string requesterName,
string viewerHubConnectionId,
IDesktopHubConnection desktopHubConnection,
IScreenCapturer screenCapturer,
IClipboardService clipboardService,
IAudioCapturer audioCapturer,
ISystemTime systemTime,
ILogger<Viewer> logger)
{
Name = requesterName;
ViewerConnectionId = viewerHubConnectionId;
Capturer = screenCapturer;
_desktopHubConnection = desktopHubConnection;
_clipboardService = clipboardService;
_audioCapturer = audioCapturer;
_systemTime = systemTime;
_logger = logger;
_clipboardService.ClipboardTextChanged += ClipboardService_ClipboardTextChanged;
_audioCapturer.AudioSampleReady += AudioCapturer_AudioSampleReady;
}
public IScreenCapturer Capturer { get; }
public double CurrentFps { get; private set; }
public double CurrentMbps { get; private set; }
public bool DisconnectRequested
{
get => _disconnectRequested;
set
{
_disconnectRequested = value;
}
}
public bool HasControl { get; set; } = true;
public int ImageQuality { get; private set; } = DefaultQuality;
public bool IsResponsive { get; private set; } = true;
public string Name { get; set; } = string.Empty;
public TimeSpan RoundTripLatency { get; private set; }
public string ViewerConnectionId { get; set; } = string.Empty;
public void AppendSentFrame(SentFrame sentFrame)
{
Interlocked.Increment(ref _framesSentSinceLastReceipt);
_lastFrameSent = sentFrame.Timestamp;
_sentFrames.Enqueue(sentFrame);
}
public Task ApplyAutoQuality()
{
if (ImageQuality < DefaultQuality)
{
ImageQuality = Math.Min(DefaultQuality, ImageQuality + 2);
}
return Task.CompletedTask;
}
public async Task CalculateMetrics()
{
if (_desktopHubConnection.Connection is null)
{
return;
}
CalculateMbps();
CalculateFps();
await CalculateLatency();
}
public void Dispose()
{
DisconnectRequested = true;
Disposer.TryDisposeAll(Capturer);
GC.SuppressFinalize(this);
}
public void IncrementFpsCount()
{
_fpsQueue.Enqueue(_systemTime.Now);
}
public async Task SendAudioSample(byte[] audioSample)
{
var dto = new AudioSampleDto(audioSample);
await TrySendToViewer(dto, DtoType.AudioSample, ViewerConnectionId);
}
public async Task SendClipboardText(string clipboardText)
{
var dto = new ClipboardTextDto(clipboardText);
await TrySendToViewer(dto, DtoType.ClipboardText, ViewerConnectionId);
}
public async Task SendCursorChange(CursorInfo cursorInfo)
{
if (cursorInfo is null)
{
return;
}
var dto = new CursorChangeDto(cursorInfo.ImageBytes, cursorInfo.HotSpot.X, cursorInfo.HotSpot.Y, cursorInfo.CssOverride);
await TrySendToViewer(dto, DtoType.CursorChange, ViewerConnectionId);
}
public async Task SendDesktopStream(IAsyncEnumerable<byte[]> stream, Guid streamId)
{
if (_desktopHubConnection.Connection is not null)
{
await _desktopHubConnection.Connection.SendAsync("SendDesktopStream", stream, streamId);
}
}
public async Task SendFile(
FileUpload fileUpload,
Action<double> progressUpdateCallback,
CancellationToken cancelToken)
{
try
{
var messageId = Guid.NewGuid().ToString();
var fileDto = new FileDto()
{
EndOfFile = false,
FileName = fileUpload.DisplayName,
MessageId = messageId,
StartOfFile = true
};
await TrySendToViewer(fileDto, DtoType.File, ViewerConnectionId);
using var fs = File.OpenRead(fileUpload.FilePath);
using var br = new BinaryReader(fs);
while (fs.Position < fs.Length)
{
if (cancelToken.IsCancellationRequested)
{
return;
}
fileDto = new FileDto()
{
Buffer = br.ReadBytes(40_000),
FileName = fileUpload.DisplayName,
MessageId = messageId
};
await TrySendToViewer(fileDto, DtoType.File, ViewerConnectionId);
progressUpdateCallback((double)fs.Position / fs.Length);
}
fileDto = new FileDto()
{
EndOfFile = true,
FileName = fileUpload.DisplayName,
MessageId = messageId,
StartOfFile = false
};
await TrySendToViewer(fileDto, DtoType.File, ViewerConnectionId);
progressUpdateCallback(1);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending file.");
}
}
public async Task SendScreenData(
string selectedDisplay,
IEnumerable<string> displayNames,
int screenWidth,
int screenHeight)
{
var dto = new ScreenDataDto()
{
MachineName = Environment.MachineName,
DisplayNames = displayNames,
SelectedDisplay = selectedDisplay,
ScreenWidth = screenWidth,
ScreenHeight = screenHeight
};
await TrySendToViewer(dto, DtoType.ScreenData, ViewerConnectionId);
}
public async Task SendScreenSize(int width, int height)
{
var dto = new ScreenSizeDto(width, height);
await TrySendToViewer(dto, DtoType.ScreenSize, ViewerConnectionId);
}
public async Task SendSessionMetrics(SessionMetricsDto metrics)
{
await TrySendToViewer(metrics, DtoType.SessionMetrics, ViewerConnectionId);
}
public async Task SendWindowsSessions()
{
if (OperatingSystem.IsWindows())
{
var dto = new WindowsSessionsDto(Win32Interop.GetActiveSessions());
await TrySendToViewer(dto, DtoType.WindowsSessions, ViewerConnectionId);
}
}
public void SetLastFrameReceived(DateTimeOffset timestamp)
{
_lastFrameReceived = timestamp;
_framesSentSinceLastReceipt = 0;
}
public async Task<bool> WaitForViewer()
{
// Prevent publisher from overwhelming consumer bewteen receipts.
var result = await WaitHelper.WaitForAsync(
() => _framesSentSinceLastReceipt < 10,
TimeSpan.FromSeconds(5));
// Prevent viewer from getting too far behind.
result &= await WaitHelper.WaitForAsync(
() => _lastFrameSent - _lastFrameReceived < TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5));
return result;
}
private async void AudioCapturer_AudioSampleReady(object? sender, byte[] sample)
{
await SendAudioSample(sample);
}
private void CalculateFps()
{
if (_fpsQueue.Count >= 2)
{
var sendTime = _fpsQueue.Last() - _fpsQueue.First();
CurrentFps = _fpsQueue.Count / sendTime.TotalSeconds;
}
else
{
CurrentFps = _fpsQueue.Count;
}
_fpsQueue.Clear();
}
private async Task CalculateLatency()
{
var latencyResult = await _desktopHubConnection.CheckRoundtripLatency(ViewerConnectionId);
if (latencyResult.IsSuccess)
{
_pingFailures = 0;
IsResponsive = true;
RoundTripLatency = latencyResult.Value;
}
else
{
_pingFailures++;
if (_pingFailures > 3)
{
IsResponsive = false;
_logger.LogWarning("Failed to check roundtrip latency: {reason}", latencyResult.Reason);
}
}
}
private void CalculateMbps()
{
if (_sentFrames.Count >= 2)
{
var sendTime = _sentFrames.Last().Timestamp - _sentFrames.First().Timestamp;
var sentBits = (double)_sentFrames.Sum(x => x.FrameSize) / 1024 / 1024 * 8;
CurrentMbps = sentBits / sendTime.TotalSeconds;
}
else if (_sentFrames.Count == 1)
{
CurrentMbps = _sentFrames.First().FrameSize / 1024 / 1024 * 8;
}
else
{
CurrentMbps = 0;
}
_sentFrames.Clear();
}
private async void ClipboardService_ClipboardTextChanged(object? sender, string clipboardText)
{
await SendClipboardText(clipboardText);
}
private async Task TrySendToViewer<T>(T dto, DtoType type, string viewerConnectionId)
{
try
{
if (!_desktopHubConnection.IsConnected)
{
_logger.LogWarning(
"Unable to send DTO type {type} because the app is disconnected from the server.",
type);
return;
}
foreach (var chunk in DtoChunker.ChunkDto(dto, type))
{
await _desktopHubConnection.SendDtoToViewer(chunk, viewerConnectionId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while sending DTO type {type} to viewer connection ID {viewerId}.",
type,
viewerConnectionId);
}
}
}

View File

@ -0,0 +1,46 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Immense.RemoteControl.Desktop.Shared.Services;
internal interface IViewerFactory
{
IViewer CreateViewer(string viewerName, string viewerConnectionId);
}
internal class ViewerFactory : IViewerFactory
{
private readonly IServiceProvider _serviceProvider;
public ViewerFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IViewer CreateViewer(string viewerName, string viewerConnectionId)
{
var desktopHubConnection = _serviceProvider.GetRequiredService<IDesktopHubConnection>();
var screenCapturer = _serviceProvider.GetRequiredService<IScreenCapturer>();
var clipboardService = _serviceProvider.GetRequiredService<IClipboardService>();
var audioCapturer = _serviceProvider.GetRequiredService<IAudioCapturer>();
var systemTime = _serviceProvider.GetRequiredService<ISystemTime>();
var logger = _serviceProvider.GetRequiredService<ILogger<Viewer>>();
return new Viewer(
viewerName,
viewerConnectionId,
desktopHubConnection,
screenCapturer,
clipboardService,
audioCapturer,
systemTime,
logger);
}
}

View File

@ -0,0 +1,99 @@
using System.CommandLine;
using CommunityToolkit.Diagnostics;
using Immense.RemoteControl.Desktop.Shared.Enums;
namespace Immense.RemoteControl.Desktop.Shared.Startup;
public static class CommandProvider
{
/// <summary>
/// Creates a <see cref="Command"/> for starting the remote control client.
/// </summary>
/// <param name="isRootCommand">Whether to create a <see cref="RootCommand"/> or <see cref="Command"/>.</param>
/// <param name="commandLineDescription">The description for the command.</param>
/// <param name="commandName">The name used to invoke the command. Required if not a root command.</param>
/// <returns></returns>
public static Command CreateRemoteControlCommand(
bool isRootCommand,
string commandLineDescription,
string commandName = "")
{
Command? rootCommand;
if (isRootCommand)
{
rootCommand = new RootCommand(commandLineDescription);
}
else
{
Guard.IsNotNullOrWhiteSpace(commandName);
rootCommand = new Command(commandName, commandLineDescription);
}
var hostOption = new Option<string>(
new[] { "-h", "--host" },
"The hostname of the server to which to connect (e.g. https://example.com).");
rootCommand.AddOption(hostOption);
var modeOption = new Option<AppMode>(
new[] { "-m", "--mode" },
() => AppMode.Attended,
"The remote control mode to use. Either Attended, Unattended, or Chat.");
rootCommand.AddOption(modeOption);
var pipeNameOption = new Option<string>(
new[] { "-p", "--pipe-name" },
"When AppMode is Chat, this is the pipe name used by the named pipes server.");
pipeNameOption.AddValidator((context) =>
{
if (context.GetValueForOption(modeOption) == AppMode.Chat &&
string.IsNullOrWhiteSpace(context.GetValueOrDefault<string>()))
{
context.ErrorMessage = "A pipe name must be specified when AppMode is Chat.";
}
});
rootCommand.AddOption(pipeNameOption);
var sessionIdOption = new Option<string>(
new[] { "-s", "--session-id" },
"In Unattended mode, this unique session ID will be assigned to this connection and " +
"shared with the server. The connection can then be found in the RemoteControlSessionCache " +
"using this ID.");
rootCommand.AddOption(sessionIdOption);
var accessKeyOption = new Option<string>(
new[] { "-a", "--access-key" },
"In Unattended mode, secures access to the connection using the provided key.");
rootCommand.AddOption(accessKeyOption);
var requesterNameOption = new Option<string>(
new[] { "-r", "--requester-name" },
"The name of the technician requesting to connect.");
rootCommand.AddOption(requesterNameOption);
var organizationNameOption = new Option<string>(
new[] { "-o", "--org-name" },
"The organization name of the technician requesting to connect.");
rootCommand.AddOption(organizationNameOption);
var relaunchOption = new Option<bool>(
"--relaunch",
"Used to indicate that process is being relaunched from a previous session " +
"and should notify viewers when it's ready.");
rootCommand.AddOption(relaunchOption);
var viewersOption = new Option<string>(
"--viewers",
"Used with --relaunch. Should be a comma-separated list of viewers' " +
"SignalR connection IDs.");
rootCommand.AddOption(viewersOption);
var elevateOption = new Option<bool>(
"--elevate",
"Must be called from a Windows service. The process will relaunch " +
"itself in the console session with elevated rights.");
rootCommand.AddOption(elevateOption);
return rootCommand;
}
}

View File

@ -0,0 +1,39 @@
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Shared.Services;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Bitbound.SimpleMessenger;
using Desktop.Shared.Services;
namespace Immense.RemoteControl.Desktop.Shared.Startup;
public static class IServiceCollectionExtensions
{
internal static void AddRemoteControlXplat(
this IServiceCollection services,
Action<IRemoteControlClientBuilder> clientConfig)
{
var builder = new RemoteControlClientBuilder(services);
clientConfig.Invoke(builder);
builder.Validate();
services.AddLogging(builder =>
{
builder.AddConsole().AddDebug();
});
services.AddSingleton<ISystemTime, SystemTime>();
services.AddSingleton<IDesktopHubConnection, DesktopHubConnection>();
services.AddSingleton<IIdleTimer, IdleTimer>();
services.AddSingleton<IImageHelper, ImageHelper>();
services.AddSingleton<IChatHostService, ChatHostService>();
services.AddSingleton(s => WeakReferenceMessenger.Default);
services.AddSingleton<IDesktopEnvironment, DesktopEnvironment>();
services.AddSingleton<IDtoMessageHandler, DtoMessageHandler>();
services.AddSingleton<IAppState, AppState>();
services.AddSingleton<IViewerFactory, ViewerFactory>();
services.AddTransient<IScreenCaster, ScreenCaster>();
services.AddTransient<IHubConnectionBuilder>(s => new HubConnectionBuilder());
}
}

View File

@ -0,0 +1,163 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Enums;
using Immense.RemoteControl.Desktop.Shared.Native.Windows;
using Immense.RemoteControl.Desktop.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Primitives;
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using System.Runtime.Versioning;
namespace Immense.RemoteControl.Desktop.Shared.Startup;
public static class IServiceProviderExtensions
{
/// <summary>
/// Runs the remote control startup with the specified arguments.
/// </summary>
public static async Task<Result> UseRemoteControlClient(
this IServiceProvider services,
string host,
AppMode mode,
string pipeName,
string sessionId,
string accessKey,
string requesterName,
string organizationName,
bool relaunch,
string viewers,
bool elevate)
{
try
{
var logger = services.GetRequiredService<ILogger<IServiceProvider>>();
TaskScheduler.UnobservedTaskException += (object? sender, UnobservedTaskExceptionEventArgs e) =>
{
HandleUnobservedTask(e, logger);
};
if (OperatingSystem.IsWindows() && elevate)
{
RelaunchElevated();
return Result.Ok();
}
var appState = services.GetRequiredService<IAppState>();
appState.Configure(
host ?? string.Empty,
mode,
sessionId ?? string.Empty,
accessKey ?? string.Empty,
requesterName ?? string.Empty,
organizationName ?? string.Empty,
pipeName ?? string.Empty,
relaunch,
viewers ?? string.Empty,
elevate);
StaticServiceProvider.Instance = services;
var appStartup = services.GetRequiredService<IAppStartup>();
await appStartup.Run();
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex);
}
}
/// <summary>
/// Runs the remote control startup as a root command. This uses the System.CommandLine package.
/// </summary>
/// <param name="services">The service provider fo rthe app using this library.</param>
/// <param name="args">The original command line arguments passed into the app.</param>
/// <param name="commandLineDescription">The description to use for the remote control command.</param>
/// <param name="serverUri">If provided, will be used as a fallback if --host option is missing.</param>
/// <returns></returns>
public static async Task<Result> UseRemoteControlClient(
this IServiceProvider services,
string[] args,
string commandLineDescription,
string serverUri = "",
bool treatUnmatchedArgsAsErrors = true)
{
try
{
var rootCommand = CommandProvider.CreateRemoteControlCommand(true, commandLineDescription);
rootCommand.Handler = CommandHandler.Create(async (
string host,
AppMode mode,
string pipeName,
string sessionId,
string accessKey,
string requesterName,
string organizationName,
bool relaunch,
string viewers,
bool elevate) =>
{
if (string.IsNullOrWhiteSpace(host) && !string.IsNullOrWhiteSpace(serverUri))
{
host = serverUri;
}
return await services.UseRemoteControlClient(
host,
mode,
pipeName,
sessionId,
accessKey,
requesterName,
organizationName,
relaunch,
viewers,
elevate);
});
rootCommand.TreatUnmatchedTokensAsErrors = treatUnmatchedArgsAsErrors;
var result = await rootCommand.InvokeAsync(args);
if (result == 0)
{
return Result.Ok();
}
return Result.Fail($"Remote control command returned code {result}.");
}
catch (Exception ex)
{
return Result.Fail(ex);
}
}
// This shouldn't be required in modern .NET to prevent the app from crashing,
// but it could be useful to log it.
private static void HandleUnobservedTask(
UnobservedTaskExceptionEventArgs e,
ILogger<IServiceProvider> logger)
{
e.SetObserved();
logger.LogError(e.Exception, "An unobserved task exception occurred.");
}
[SupportedOSPlatform("windows")]
private static void RelaunchElevated()
{
var commandLine = Win32Interop.GetCommandLine().Replace(" --elevate", "");
Console.WriteLine($"Elevating process {commandLine}.");
var result = Win32Interop.CreateInteractiveSystemProcess(
commandLine,
-1,
false,
"default",
true,
out var procInfo);
Console.WriteLine($"Elevate result: {result}. Process ID: {procInfo.dwProcessId}.");
Environment.Exit(0);
}
}

View File

@ -0,0 +1,42 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Immense.RemoteControl.Desktop.Shared.Startup;
public interface IRemoteControlClientBuilder
{
void AddBrandingProvider<T>()
where T : class, IBrandingProvider;
}
internal class RemoteControlClientBuilder : IRemoteControlClientBuilder
{
private readonly IServiceCollection _services;
public RemoteControlClientBuilder(IServiceCollection services)
{
_services = services;
}
public void AddBrandingProvider<T>()
where T : class, IBrandingProvider
{
_services.AddSingleton<IBrandingProvider, T>();
}
internal void Validate()
{
var serviceTypes = new[]
{
typeof(IBrandingProvider)
};
foreach (var type in serviceTypes)
{
if (!_services.Any(x => x.ServiceType == type))
{
throw new Exception($"Missing service registration for type {type.Name}.");
}
}
}
}

View File

@ -0,0 +1,6 @@
namespace Immense.RemoteControl.Desktop.Shared;
public static class StaticServiceProvider
{
public static IServiceProvider? Instance { get; set; }
}

View File

@ -0,0 +1,23 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
namespace Immense.RemoteControl.Desktop.Shared.ViewModels;
public partial class FileUpload : ObservableObject
{
public string FilePath
{
get => Get(defaultValue: string.Empty);
set => Set(value);
}
public double PercentProgress
{
get => Get<double>();
set => Set(value);
}
public CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource();
public string DisplayName => Path.GetFileName(FilePath);
}

30
Desktop.UI/App.axaml Normal file
View File

@ -0,0 +1,30 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Immense.RemoteControl.Desktop.UI.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<Style Selector="TextBlock.SectionHeader">
<Setter Property="FontWeight" Value="Bold"></Setter>
<Setter Property="FontSize" Value="18"></Setter>
</Style>
<Style Selector="Button.NormalButton">
<Setter Property="Background" Value="White"></Setter>
<Setter Property="BorderThickness" Value="1"></Setter>
<Setter Property="BorderBrush" Value="Black"></Setter>
<Setter Property="Padding" Value="6,4"></Setter>
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.HyperlinkButton">
<Setter Property="Foreground" Value="DodgerBlue"></Setter>
<Setter Property="BorderThickness" Value="0"></Setter>
<Setter Property="BorderBrush" Value="Transparent"></Setter>
<Setter Property="Cursor" Value="Hand"></Setter>
<Setter Property="Background" Value="Transparent"></Setter>
<Setter Property="Padding" Value="0"></Setter>
</Style>
</Application.Styles>
</Application>

32
Desktop.UI/App.axaml.cs Normal file
View File

@ -0,0 +1,32 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
namespace Immense.RemoteControl.Desktop.UI;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
// Line below is needed to remove Avalonia data validation.
// Without this line you will get duplicate validations from both Avalonia and CT
BindingPlugins.DataValidators.RemoveAt(0);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = new MainView();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Desktop.UI/Assets/Gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:vm="clr-namespace:Immense.RemoteControl.Desktop.UI.ViewModels"
xmlns:fakes="clr-namespace:Immense.RemoteControl.Desktop.UI.ViewModels.Fakes"
x:Class="Immense.RemoteControl.Desktop.UI.Controls.Dialogs.MessageBox"
Icon="{Binding WindowIcon}"
Title="{Binding Caption}"
Topmost="True"
ShowActivated="True"
SizeToContent="WidthAndHeight" MinWidth="300" MinHeight="100"
x:DataType="vm:MessageBoxViewModel"
x:Name="MessageBoxWindow"
WindowStartupLocation="CenterScreen">
<Design.DataContext>
<fakes:FakeMessageBoxViewModel />
</Design.DataContext>
<StackPanel Margin="10, 20">
<TextBlock Text="{Binding Message}"></TextBlock>
<Grid Margin="0,20,0,0">
<Button Classes="NormalButton" HorizontalAlignment="Right" IsVisible="{Binding IsOkButtonVisible}"
Command="{Binding OKCommand}"
CommandParameter="{Binding #MessageBoxWindow}"
Content="OK">
</Button>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Classes="NormalButton" HorizontalAlignment="Right" IsVisible="{Binding AreYesNoButtonsVisible}" Margin="5,0,5,0"
Command="{Binding YesCommand}"
CommandParameter="{Binding #MessageBoxWindow}"
Content="Yes">
</Button>
<Button Classes="NormalButton" HorizontalAlignment="Right" IsVisible="{Binding AreYesNoButtonsVisible}" Margin="5,0,5,0"
Command="{Binding NoCommand}"
CommandParameter="{Binding #MessageBoxWindow}"
Content="No">
</Button>
</StackPanel>
</Grid>
</StackPanel>
</Window>

View File

@ -0,0 +1,51 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using CommunityToolkit.Diagnostics;
using Immense.RemoteControl.Desktop.Shared;
using Microsoft.Extensions.DependencyInjection;
using System.Threading;
namespace Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
public partial class MessageBox : Window
{
public MessageBox()
{
InitializeComponent();
}
public static async Task<MessageBoxResult> Show(string message, string caption, MessageBoxType type)
{
Guard.IsNotNull(StaticServiceProvider.Instance, nameof(StaticServiceProvider.Instance));
var dispatcher = StaticServiceProvider.Instance.GetRequiredService<IUiDispatcher>();
return await dispatcher.InvokeAsync(async () =>
{
var viewModel = StaticServiceProvider.Instance.GetRequiredService<IMessageBoxViewModel>();
var messageBox = new MessageBox()
{
DataContext = viewModel
};
viewModel.Caption = caption;
viewModel.Message = message;
switch (type)
{
case MessageBoxType.OK:
viewModel.IsOkButtonVisible = true;
break;
case MessageBoxType.YesNo:
viewModel.AreYesNoButtonsVisible = true;
break;
default:
break;
}
await dispatcher.ShowDialog(messageBox);
return viewModel.Result;
});
}
}

View File

@ -0,0 +1,9 @@
namespace Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
public enum MessageBoxResult
{
Cancel,
OK,
Yes,
No
}

View File

@ -0,0 +1,7 @@
namespace Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
public enum MessageBoxType
{
OK,
YesNo
}

View File

@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ValidateExecutableReferencesMatchSelfContained>false</ValidateExecutableReferencesMatchSelfContained>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\avalonia-logo.ico" />
<None Remove="Assets\DefaultIcon.ico" />
<None Remove="Assets\DefaultIcon.png" />
<None Remove="Assets\Gear.png" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\avalonia-logo.ico" />
<AvaloniaResource Include="Assets\DefaultIcon.ico" />
<AvaloniaResource Include="Assets\DefaultIcon.png" />
<AvaloniaResource Include="Assets\Gear.png" />
</ItemGroup>
<ItemGroup>
<!--This helps with theme dll-s trimming.
If you will publish your application in self-contained mode with p:PublishTrimmed=true and it will use Fluent theme Default theme will be trimmed from the output and vice versa.
https://github.com/AvaloniaUI/Avalonia/issues/5593 -->
<TrimmableAssembly Include="Avalonia.Themes.Fluent" />
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.11" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.11" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.11" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.11" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Desktop.Shared\Desktop.Shared.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
global using System;
global using System.IO;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
global using Immense.RemoteControl.Desktop.UI.Services;
global using Immense.RemoteControl.Desktop.UI.ViewModels;
global using Immense.RemoteControl.Desktop.UI.Views;

View File

@ -0,0 +1,67 @@
using Avalonia.Controls;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using System.ComponentModel;
using Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
using CommunityToolkit.Diagnostics;
using Remotely.Shared.Models;
namespace Immense.RemoteControl.Desktop.UI.Services;
public class ChatUiService : IChatUiService
{
private readonly IUiDispatcher _dispatcher;
private readonly IDialogProvider _dialogProvider;
private readonly IViewModelFactory _viewModelFactory;
private IChatWindowViewModel? _chatViewModel;
public ChatUiService(
IUiDispatcher dispatcher,
IDialogProvider dialogProvider,
IViewModelFactory viewModelFactory)
{
_dispatcher = dispatcher;
_dialogProvider = dialogProvider;
_viewModelFactory = viewModelFactory;
}
public event EventHandler? ChatWindowClosed;
public async Task ReceiveChat(ChatMessage chatMessage)
{
await _dispatcher.InvokeAsync(async () =>
{
if (chatMessage.Disconnected)
{
await _dialogProvider.Show("Your partner has disconnected from the chat.", "Partner Disconnected", MessageBoxType.OK);
Environment.Exit(0);
return;
}
if (_chatViewModel != null)
{
_chatViewModel.SenderName = chatMessage.SenderName;
_chatViewModel.ChatMessages.Add(chatMessage);
}
});
}
public void ShowChatWindow(string organizationName, StreamWriter writer)
{
_dispatcher.Post(() =>
{
_chatViewModel = _viewModelFactory.CreateChatWindowViewModel(organizationName, writer);
var chatWindow = new ChatWindow()
{
DataContext = _chatViewModel
};
chatWindow.Closing += ChatWindow_Closing;
_dispatcher.ShowMainWindow(chatWindow);
});
}
private void ChatWindow_Closing(object? sender, CancelEventArgs e)
{
ChatWindowClosed?.Invoke(this, e);
}
}

View File

@ -0,0 +1,88 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using System.Threading;
namespace Immense.RemoteControl.Desktop.UI.Services;
public class ClipboardService : IClipboardService
{
private readonly IUiDispatcher _dispatcher;
private readonly ILogger<ClipboardService> _logger;
private Task? _watcherTask;
public event EventHandler<string>? ClipboardTextChanged;
public ClipboardService(
IUiDispatcher dispatcher,
ILogger<ClipboardService> logger)
{
_dispatcher = dispatcher;
_logger = logger;
}
private string ClipboardText { get; set; } = string.Empty;
public void BeginWatching()
{
if (_watcherTask?.Status == TaskStatus.Running)
{
return;
}
_watcherTask = Task.Run(
async () => await WatchClipboard(_dispatcher.ApplicationExitingToken),
_dispatcher.ApplicationExitingToken);
}
public async Task SetText(string clipboardText)
{
try
{
if (_dispatcher?.Clipboard is null)
{
_logger.LogWarning("Clipboard is null.");
return;
}
if (string.IsNullOrWhiteSpace(clipboardText))
{
await _dispatcher.Clipboard.ClearAsync();
}
else
{
await _dispatcher.Clipboard.SetTextAsync(clipboardText);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while setting text.");
}
}
private async Task WatchClipboard(CancellationToken cancelToken)
{
while (
!cancelToken.IsCancellationRequested &&
!Environment.HasShutdownStarted)
{
try
{
if (_dispatcher?.Clipboard is null)
{
continue;
}
var currentText = await _dispatcher.Clipboard.GetTextAsync();
if (!string.IsNullOrEmpty(currentText) && currentText != ClipboardText)
{
ClipboardText = currentText;
ClipboardTextChanged?.Invoke(this, ClipboardText);
}
}
finally
{
Thread.Sleep(500);
}
}
}
}

View File

@ -0,0 +1,22 @@
using Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Immense.RemoteControl.Desktop.UI.Services;
public interface IDialogProvider
{
Task<MessageBoxResult> Show(string message, string caption, MessageBoxType type);
}
internal class DialogProvider : IDialogProvider
{
public async Task<MessageBoxResult> Show(string message, string caption, MessageBoxType type)
{
return await MessageBox.Show(message, caption, type);
}
}

View File

@ -0,0 +1,62 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Shared.Enums;
using Microsoft.Extensions.Logging;
using System.Threading;
namespace Immense.RemoteControl.Desktop.UI.Services;
public class RemoteControlAccessService : IRemoteControlAccessService
{
private readonly IViewModelFactory _viewModelFactory;
private readonly IUiDispatcher _dispatcher;
private readonly ILogger<RemoteControlAccessService> _logger;
private volatile int _promptCount = 0;
public RemoteControlAccessService(
IViewModelFactory viewModelFactory,
IUiDispatcher dispatcher,
ILogger<RemoteControlAccessService> logger)
{
_viewModelFactory = viewModelFactory;
_dispatcher = dispatcher;
_logger = logger;
}
public bool IsPromptOpen => _promptCount > 0;
public async Task<PromptForAccessResult> PromptForAccess(string requesterName, string organizationName)
{
return await _dispatcher.InvokeAsync(async () =>
{
try
{
Interlocked.Increment(ref _promptCount);
var viewModel = _viewModelFactory.CreatePromptForAccessViewModel(requesterName, organizationName);
var promptWindow = new PromptForAccessWindow()
{
DataContext = viewModel
};
var result = await _dispatcher.Show(promptWindow, TimeSpan.FromMinutes(1));
if (!result)
{
return PromptForAccessResult.TimedOut;
}
return viewModel.PromptResult ?
PromptForAccessResult.Accepted :
PromptForAccessResult.Denied;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while prompting for remote control access.");
return PromptForAccessResult.Error;
}
finally
{
Interlocked.Decrement(ref _promptCount);
}
});
}
}

View File

@ -0,0 +1,26 @@
using Immense.RemoteControl.Desktop.Shared;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Immense.RemoteControl.Desktop.UI.Services;
public class SessionIndicator : ISessionIndicator
{
private readonly IUiDispatcher _dispatcher;
public SessionIndicator(IUiDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public void Show()
{
_dispatcher.Post(() =>
{
var indicatorWindow = new SessionIndicatorWindow()
{
DataContext = StaticServiceProvider.Instance?.GetRequiredService<ISessionIndicatorWindowViewModel>()
};
_dispatcher.ShowMainWindow(indicatorWindow);
});
}
}

View File

@ -0,0 +1,255 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Threading;
using Immense.RemoteControl.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Primitives;
using System.Threading;
namespace Immense.RemoteControl.Desktop.UI.Services;
public interface IUiDispatcher
{
CancellationToken ApplicationExitingToken { get; }
IClipboard? Clipboard { get; }
Application? CurrentApp { get; }
Window? MainWindow { get; }
void Invoke(Action action);
Task InvokeAsync(Action action, DispatcherPriority priority = default);
Task InvokeAsync(Func<Task> func, DispatcherPriority priority = default);
Task<T> InvokeAsync<T>(Func<Task<T>> func, DispatcherPriority priority = default);
void Post(Action action, DispatcherPriority priority = default);
Task<bool> Show(Window window, TimeSpan timeout);
Task ShowDialog(Window window);
void ShowMainWindow(Window window);
void ShowWindow(Window window);
void Shutdown();
void StartClassicDesktop();
Task<Result> StartHeadless();
}
internal class UiDispatcher : IUiDispatcher
{
private static readonly CancellationTokenSource _appCts = new();
private static Application? _currentApp;
private readonly ILogger<UiDispatcher> _logger;
private AppBuilder? _appBuilder;
private Window? _headlessMainWindow;
public UiDispatcher(ILogger<UiDispatcher> logger)
{
_logger = logger;
}
public CancellationToken ApplicationExitingToken => _appCts.Token;
public IClipboard? Clipboard
{
get
{
if (CurrentApp?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopApp)
{
return desktopApp.MainWindow?.Clipboard;
}
if (CurrentApp?.ApplicationLifetime is ISingleViewApplicationLifetime svApp)
{
return TopLevel.GetTopLevel(svApp.MainView)?.Clipboard;
}
if (_headlessMainWindow is not null)
{
return _headlessMainWindow.Clipboard;
}
return null;
}
}
public Application? CurrentApp => _currentApp ?? _appBuilder?.Instance;
public Window? MainWindow
{
get
{
if (CurrentApp?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime app)
{
return app.MainWindow;
}
return _headlessMainWindow;
}
}
public void Invoke(Action action)
{
Dispatcher.UIThread.Invoke(action);
}
public Task InvokeAsync(Func<Task> func, DispatcherPriority priority = default)
{
return Dispatcher.UIThread.InvokeAsync(func, priority);
}
public Task<T> InvokeAsync<T>(Func<Task<T>> func, DispatcherPriority priority = default)
{
return Dispatcher.UIThread.InvokeAsync(func, priority);
}
public async Task InvokeAsync(Action action, DispatcherPriority priority = default)
{
await Dispatcher.UIThread.InvokeAsync(action, priority);
}
public void Post(Action action, DispatcherPriority priority = default)
{
Dispatcher.UIThread.Post(action, priority);
}
public async Task<bool> Show(Window window, TimeSpan timeout)
{
return await Dispatcher.UIThread.InvokeAsync(async () =>
{
using var closeSignal = new SemaphoreSlim(0, 1);
window.Closed += (sender, arg) =>
{
closeSignal.Release();
};
window.Show();
var result = await closeSignal.WaitAsync(timeout);
if (!result)
{
window.Close();
}
return result;
});
}
public async Task ShowDialog(Window window)
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (MainWindow is not null)
{
await window.ShowDialog(MainWindow);
}
else
{
using var closeSignal = new SemaphoreSlim(0, 1);
window.Closed += (sender, arg) =>
{
closeSignal.Release();
};
window.Show();
await closeSignal.WaitAsync();
}
});
}
public void ShowMainWindow(Window window)
{
Dispatcher.UIThread.Invoke(() =>
{
_headlessMainWindow = window;
window.Show();
});
}
public void ShowWindow(Window window)
{
Dispatcher.UIThread.Invoke(() =>
{
if (MainWindow is not null)
{
window.Show(MainWindow);
}
else
{
window.Show();
}
});
}
public void Shutdown()
{
_appCts.Cancel();
if (CurrentApp?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime &&
lifetime.TryShutdown())
{
return;
}
Environment.Exit(0);
}
public void StartClassicDesktop()
{
try
{
var args = Environment.GetCommandLineArgs();
_appBuilder = BuildAvaloniaApp();
_appBuilder.StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while starting foreground app.");
throw;
}
}
public async Task<Result> StartHeadless()
{
try
{
var args = Environment.GetCommandLineArgs();
var argString = string.Join(" ", args);
_logger.LogInformation("Starting dispatcher in unattended mode with args: [{args}].", argString);
_ = Task.Run(() =>
{
_appBuilder = BuildAvaloniaApp();
_appBuilder.Start(RunHeadless, args);
}, _appCts.Token);
var waitResult = await WaitHelper.WaitForAsync(
() => CurrentApp is not null,
TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
if (!waitResult)
{
const string err = "Unattended dispatcher failed to start in time.";
_logger.LogError(err);
Shutdown();
return Result.Fail(err);
}
return Result.Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while starting background app.");
return Result.Fail(ex);
}
}
// Avalonia configuration, don't remove; also used by visual designer.
private static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
private static void RunHeadless(Application app, string[] args)
{
_currentApp = app;
app.Run(_appCts.Token);
}
}

View File

@ -0,0 +1,60 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.IO;
namespace Immense.RemoteControl.Desktop.UI.Services;
// Normally, I'd use a view model locator. But enough view models require a factory pattern
// that I thought it more consistent to put them all here.
public interface IViewModelFactory
{
IChatWindowViewModel CreateChatWindowViewModel(string organizationName, StreamWriter streamWriter);
IFileTransferWindowViewModel CreateFileTransferWindowViewModel(IViewer viewer);
IHostNamePromptViewModel CreateHostNamePromptViewModel();
IPromptForAccessWindowViewModel CreatePromptForAccessViewModel(string requesterName, string organizationName);
}
internal class ViewModelFactory : IViewModelFactory
{
private readonly IServiceProvider _serviceProvider;
public ViewModelFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IChatWindowViewModel CreateChatWindowViewModel(string organizationName, StreamWriter streamWriter)
{
var branding = _serviceProvider.GetRequiredService<IBrandingProvider>();
var dispatcher = _serviceProvider.GetRequiredService<IUiDispatcher>();
var logger = _serviceProvider.GetRequiredService<ILogger<ChatWindowViewModel>>();
return new ChatWindowViewModel(streamWriter, organizationName, branding, dispatcher, logger);
}
public IFileTransferWindowViewModel CreateFileTransferWindowViewModel(IViewer viewer)
{
var brandingProvider = _serviceProvider.GetRequiredService<IBrandingProvider>();
var dispatcher = _serviceProvider.GetRequiredService<IUiDispatcher>();
var logger = _serviceProvider.GetRequiredService<ILogger<FileTransferWindowViewModel>>();
var fileTransfer = _serviceProvider.GetRequiredService<IFileTransferService>();
return new FileTransferWindowViewModel(viewer, brandingProvider, dispatcher, fileTransfer, logger);
}
public IPromptForAccessWindowViewModel CreatePromptForAccessViewModel(string requesterName, string organizationName)
{
var brandingProvider = _serviceProvider.GetRequiredService<IBrandingProvider>();
var dispatcher = _serviceProvider.GetRequiredService<IUiDispatcher>();
var logger = _serviceProvider.GetRequiredService<ILogger<PromptForAccessWindowViewModel>>();
return new PromptForAccessWindowViewModel(requesterName, organizationName, brandingProvider, dispatcher, logger);
}
public IHostNamePromptViewModel CreateHostNamePromptViewModel()
{
var brandingProvider = _serviceProvider.GetRequiredService<IBrandingProvider>();
var dispatcher = _serviceProvider.GetRequiredService<IUiDispatcher>();
var logger = _serviceProvider.GetRequiredService<ILogger<HostNamePromptViewModel>>();
return new HostNamePromptViewModel(brandingProvider, dispatcher, logger);
}
}

View File

@ -0,0 +1,23 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Immense.RemoteControl.Desktop.UI.Startup;
public static class IServiceCollectionExtensions
{
public static void AddRemoteControlUi(
this IServiceCollection services)
{
services.AddSingleton<IUiDispatcher, UiDispatcher>();
services.AddSingleton<IChatUiService, ChatUiService>();
services.AddSingleton<IClipboardService, ClipboardService>();
services.AddSingleton<ISessionIndicator, SessionIndicator>();
services.AddSingleton<IRemoteControlAccessService, RemoteControlAccessService>();
services.AddSingleton<IViewModelFactory, ViewModelFactory>();
services.AddSingleton<IMainWindowViewModel, MainWindowViewModel>();
services.AddSingleton<IMainViewViewModel, MainViewViewModel>();
services.AddSingleton<ISessionIndicatorWindowViewModel, SessionIndicatorWindowViewModel>();
services.AddTransient<IMessageBoxViewModel, MessageBoxViewModel>();
services.AddSingleton<IDialogProvider, DialogProvider>();
}
}

View File

@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Reactive;
using Immense.RemoteControl.Shared.Models;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Entities;
using System.IO;
using System.Reflection;
namespace Immense.RemoteControl.Desktop.UI.ViewModels;
public interface IBrandedViewModelBase
{
Bitmap? Icon { get; set; }
string ProductName { get; set; }
WindowIcon? WindowIcon { get; set; }
}
public class BrandedViewModelBase : ObservableObject, IBrandedViewModelBase
{
private static BrandingInfo? _brandingInfo;
protected readonly ILogger<BrandedViewModelBase> _logger;
protected readonly IUiDispatcher _dispatcher;
private readonly IBrandingProvider _brandingProvider;
public BrandedViewModelBase(
IBrandingProvider brandingProvider,
IUiDispatcher dispatcher,
ILogger<BrandedViewModelBase> logger)
{
_brandingProvider = brandingProvider;
_dispatcher = dispatcher;
_logger = logger;
ApplyBrandingImpl();
}
public Bitmap? Icon
{
get => Get<Bitmap?>();
set => Set(value);
}
public string ProductName
{
get => Get<string?>() ?? "Remote Control";
set => Set(value ?? "Remote Control");
}
public WindowIcon? WindowIcon
{
get => Get<WindowIcon?>();
set => Set(value);
}
private void ApplyBrandingImpl()
{
_dispatcher.Invoke(() =>
{
try
{
_brandingInfo ??= _brandingProvider.CurrentBranding;
ProductName = _brandingInfo.Product;
if (_brandingInfo.Icon?.Any() == true)
{
using var imageStream = new MemoryStream(_brandingInfo.Icon);
Icon = new Bitmap(imageStream);
}
else
{
using var imageStream =
Assembly
.GetExecutingAssembly()
.GetManifestResourceStream("Immense.RemoteControl.Desktop.Shared.Assets.DefaultIcon.png") ?? new MemoryStream();
Icon = new Bitmap(imageStream);
}
WindowIcon = new WindowIcon(Icon);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying branding.");
}
});
}
}

View File

@ -0,0 +1,115 @@
using Avalonia.Controls;
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
using System.Windows.Input;
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using Immense.RemoteControl.Desktop.Shared.Reactive;
using Microsoft.Extensions.DependencyInjection;
using Remotely.Shared.Models;
namespace Immense.RemoteControl.Desktop.UI.ViewModels;
public interface IChatWindowViewModel : IBrandedViewModelBase
{
ObservableCollection<ChatMessage> ChatMessages { get; }
string ChatSessionHeader { get; }
ICommand CloseCommand { get; }
string InputText { get; set; }
ICommand MinimizeCommand { get; }
string OrganizationName { get; set; }
string SenderName { get; set; }
Task SendChatMessage();
}
public class ChatWindowViewModel : BrandedViewModelBase, IChatWindowViewModel
{
private readonly StreamWriter? _streamWriter;
[ActivatorUtilitiesConstructor]
public ChatWindowViewModel(
StreamWriter streamWriter,
string organizationName,
IBrandingProvider brandingProvider,
IUiDispatcher dispatcher,
ILogger<ChatWindowViewModel> logger)
: base(brandingProvider, dispatcher, logger)
{
_streamWriter = streamWriter;
if (!string.IsNullOrWhiteSpace(organizationName))
{
OrganizationName = organizationName;
}
CloseCommand = new RelayCommand<Window>(CloseWindow);
MinimizeCommand = new RelayCommand<Window>(MinimizeWindow);
}
public ObservableCollection<ChatMessage> ChatMessages { get; } = new ObservableCollection<ChatMessage>();
public string ChatSessionHeader => $"Chat session with {OrganizationName}";
public ICommand CloseCommand { get; }
public string InputText
{
get => Get<string>() ?? string.Empty;
set => Set(value);
}
public ICommand MinimizeCommand { get; }
public string OrganizationName
{
get => Get<string>() ?? "your IT provider";
set
{
Set(value);
NotifyPropertyChanged(nameof(ChatSessionHeader));
}
}
public string SenderName
{
get => Get<string>() ?? "a technician";
set => Set(value);
}
public async Task SendChatMessage()
{
if (string.IsNullOrWhiteSpace(InputText) ||
_streamWriter is null)
{
return;
}
try
{
var chatMessage = new ChatMessage(string.Empty, InputText);
InputText = string.Empty;
await _streamWriter.WriteLineAsync(JsonSerializer.Serialize(chatMessage));
await _streamWriter.FlushAsync();
chatMessage.SenderName = "You";
ChatMessages.Add(chatMessage);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending chat message");
}
}
private void CloseWindow(Window? obj)
{
obj?.Close();
}
private void MinimizeWindow(Window? obj)
{
if (obj is not null)
{
obj.WindowState = WindowState.Minimized;
}
}
}

View File

@ -0,0 +1,51 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
using Immense.RemoteControl.Shared.Models;
using Remotely.Shared.Entities;
using System.Diagnostics;
using System.IO;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeBrandedViewModelBase : IBrandedViewModelBase
{
private readonly BrandingInfo _brandingInfo;
private Bitmap? _icon;
public FakeBrandedViewModelBase()
{
_brandingInfo = new BrandingInfo();
_icon = GetBitmapImageIcon(_brandingInfo);
}
public Bitmap? Icon
{
get => _icon;
set => _icon = value;
}
public string ProductName { get; set; } = "Test Product";
public WindowIcon? WindowIcon { get; set; }
public Task ApplyBranding()
{
return Task.CompletedTask;
}
private Bitmap? GetBitmapImageIcon(BrandingInfo bi)
{
try
{
using var imageStream = typeof(Shared.Services.AppState)
.Assembly
.GetManifestResourceStream("Immense.RemoteControl.Desktop.Shared.Assets.DefaultIcon.png") ?? new MemoryStream();
return new Bitmap(imageStream);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return null;
}
}
}

View File

@ -0,0 +1,41 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
using Remotely.Shared.Models;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeChatWindowViewModel : FakeBrandedViewModelBase, IChatWindowViewModel
{
public ObservableCollection<ChatMessage> ChatMessages { get; } = new()
{
new ChatMessage("Designer", "This is a design-time test message.")
};
public string InputText
{
get => "Some text I'm going to send.";
set { }
}
public string OrganizationName
{
get => "Design-Time Technicians";
set { }
}
public string SenderName
{
get => "Test Tech";
set { }
}
public string ChatSessionHeader => "Test Chat";
public ICommand CloseCommand => new RelayCommand(() => { });
public ICommand MinimizeCommand => new RelayCommand(() => { });
public Task SendChatMessage()
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,33 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
using Immense.RemoteControl.Desktop.Shared.ViewModels;
using System.Collections.ObjectModel;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeFileTransferViewModel : FakeBrandedViewModelBase, IFileTransferWindowViewModel
{
public ObservableCollection<FileUpload> FileUploads { get; } = new();
public string ViewerConnectionId { get; set; } = string.Empty;
public string ViewerName { get; set; } = string.Empty;
public ICommand OpenFileUploadDialogCommand { get; } = new RelayCommand(() => { });
public ICommand RemoveFileUploadCommand { get; } = new RelayCommand(() => { });
public Task OpenFileUploadDialog()
{
return Task.CompletedTask;
}
public void RemoveFileUpload(FileUpload? fileUpload)
{
}
public Task UploadFile(string filePath)
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,11 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeHostNamePromptViewModel : FakeBrandedViewModelBase, IHostNamePromptViewModel
{
public string Host { get; set; } = "https://localhost:7024";
public ICommand OKCommand => new RelayCommand(() => { });
}

View File

@ -0,0 +1,169 @@
using Immense.RemoteControl.Desktop.Shared.Abstractions;
using Immense.RemoteControl.Desktop.Shared.Reactive;
using Immense.RemoteControl.Desktop.Shared.Services;
using Immense.RemoteControl.Desktop.Shared.ViewModels;
using Immense.RemoteControl.Shared.Models;
using Immense.RemoteControl.Shared.Models.Dtos;
using System.Collections.ObjectModel;
using System.Threading;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeMainViewViewModel : FakeBrandedViewModelBase, IMainViewViewModel
{
public AsyncRelayCommand ChangeServerCommand => new(() => Task.CompletedTask);
public ICommand CloseCommand => new RelayCommand(() => { });
public AsyncRelayCommand CopyLinkCommand => new(() => Task.CompletedTask);
public double CopyMessageOpacity { get; set; }
public string Host { get; set; } = string.Empty;
public bool IsAdministrator => true;
public bool IsCopyMessageVisible { get; set; }
public ICommand MinimizeCommand => new RelayCommand(() => { });
public ICommand OpenOptionsMenu => new RelayCommand(() => { });
public AsyncRelayCommand RemoveViewersCommand => new(() => Task.CompletedTask);
public string StatusMessage { get; set; } = "392 527 094";
public ObservableCollection<IViewer> Viewers { get; } = new() { new FakeViewer() };
public IList<IViewer> SelectedViewers { get; } = new List<IViewer>();
public bool CanRemoveViewers()
{
return true;
}
public Task ChangeServer()
{
throw new NotImplementedException();
}
public Task CopyLink()
{
return Task.CompletedTask;
}
public Task GetSessionID()
{
return Task.CompletedTask;
}
public Task Init()
{
return Task.CompletedTask;
}
public Task PromptForHostName()
{
return Task.CompletedTask;
}
public Task RemoveViewers()
{
return Task.CompletedTask;
}
private class FakeViewer : IViewer
{
public IScreenCapturer Capturer => null!;
public double CurrentFps => default;
public double CurrentMbps => default;
public bool DisconnectRequested { get; set; } = false;
public bool HasControl { get; set; } = true;
public int ImageQuality => 80;
public bool IsResponsive => true;
public string Name { get; set; } = "Rick James";
public TimeSpan RoundTripLatency => default;
public string ViewerConnectionId { get; set; } = string.Empty;
public void AppendSentFrame(SentFrame sentFrame)
{
}
public Task ApplyAutoQuality()
{
return Task.CompletedTask;
}
public Task CalculateMetrics()
{
return Task.CompletedTask;
}
public void Dispose()
{
}
public void IncrementFpsCount()
{
}
public Task SendAudioSample(byte[] audioSample)
{
return Task.CompletedTask;
}
public Task SendClipboardText(string clipboardText)
{
return Task.CompletedTask;
}
public Task SendCursorChange(CursorInfo cursorInfo)
{
return Task.CompletedTask;
}
public Task SendDesktopStream(IAsyncEnumerable<byte[]> asyncEnumerable, Guid streamId)
{
return Task.CompletedTask;
}
public Task SendFile(FileUpload fileUpload, Action<double> progressUpdateCallback, CancellationToken cancelToken)
{
return Task.CompletedTask;
}
public Task SendScreenData(string selectedDisplay, IEnumerable<string> displayNames, int screenWidth, int screenHeight)
{
return Task.CompletedTask;
}
public Task SendScreenSize(int width, int height)
{
return Task.CompletedTask;
}
public Task SendSessionMetrics(SessionMetricsDto metrics)
{
return Task.CompletedTask;
}
public Task SendWindowsSessions()
{
return Task.CompletedTask;
}
public void SetLastFrameReceived(DateTimeOffset timestamp)
{
}
public Task<bool> WaitForViewer()
{
return Task.FromResult(true);
}
}
}

View File

@ -0,0 +1,5 @@
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeMainWindowViewModel : FakeBrandedViewModelBase, IMainWindowViewModel
{
}

View File

@ -0,0 +1,21 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
using System.Windows.Input;
using Immense.RemoteControl.Desktop.UI.Controls.Dialogs;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakeMessageBoxViewModel : FakeBrandedViewModelBase, IMessageBoxViewModel
{
public bool AreYesNoButtonsVisible { get; set; } = true;
public string Caption { get; set; } = "Test Caption";
public bool IsOkButtonVisible { get; set; } = false;
public string Message { get; set; } = "This is a test message.";
public ICommand NoCommand => new RelayCommand(() => { });
public ICommand OKCommand => new RelayCommand(() => { });
public MessageBoxResult Result { get; set; } = MessageBoxResult.Yes;
public ICommand YesCommand => new RelayCommand(() => { });
}

View File

@ -0,0 +1,23 @@
using Immense.RemoteControl.Desktop.Shared.Reactive;
using System.Windows.Input;
namespace Immense.RemoteControl.Desktop.UI.ViewModels.Fakes;
public class FakePromptForAccessViewModel : FakeBrandedViewModelBase, IPromptForAccessWindowViewModel
{
public string OrganizationName { get; set; } = "Test Organization";
public bool PromptResult { get; set; }
public string RequesterName { get; set; } = "Test Requester";
public ICommand CloseCommand => new RelayCommand(() => { });
public ICommand MinimizeCommand => new RelayCommand(() => { });
public string RequestMessage => "Test request message";
public ICommand SetResultNo => new RelayCommand(() => { });
public ICommand SetResultYes => new RelayCommand(() => { });
}

Some files were not shown because too many files have changed in this diff Show More