diff --git a/.gitmodules b/.gitmodules
index 1990546c7..e69de29bb 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +0,0 @@
-[submodule "submodules/Immense.RemoteControl"]
- path = submodules/Immense.RemoteControl
- url = git@github.com:immense/RemoteControl.git
diff --git a/Agent.Installer.Win/Agent.Installer.Win.csproj b/Agent.Installer.Win/Agent.Installer.Win.csproj
deleted file mode 100644
index 42b0ce376..000000000
--- a/Agent.Installer.Win/Agent.Installer.Win.csproj
+++ /dev/null
@@ -1,210 +0,0 @@
-
-
-
-
- Debug
- AnyCPU
- {A3D0368C-0850-4614-B5B5-41B9D5135AA9}
- WinExe
- Remotely.Agent.Installer.Win
- Remotely_Installer
- v4.8
- 512
- {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
- 4
- true
- true
-
- 10
- enable
-
-
- win;win-x64;win10-x64;win-x64;win10-x86;
-
-
- AnyCPU
- true
- full
- false
- bin\Debug\
- DEBUG;TRACE
- prompt
- 4
-
-
- AnyCPU
- pdbonly
- true
- bin\Release\
- TRACE
- prompt
- 4
-
-
- Assets\favicon.ico
-
-
- app.manifest
-
-
- true
- bin\x64\Debug\
- DEBUG;TRACE
- full
- x64
- 7.3
- prompt
- MinimumRecommendedRules.ruleset
- true
-
-
- bin\x64\Release\
- TRACE
- true
- pdbonly
- x64
- 7.3
- prompt
- MinimumRecommendedRules.ruleset
- true
-
-
- true
- bin\x86\Debug\
- DEBUG;TRACE
- full
- x86
- 7.3
- prompt
- MinimumRecommendedRules.ruleset
- true
-
-
- bin\x86\Release\
- TRACE
- true
- pdbonly
- x86
- 7.3
- prompt
- MinimumRecommendedRules.ruleset
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
- C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2\System.Runtime.Serialization.dll
-
-
-
-
-
-
-
-
-
-
- 4.0
-
-
-
-
-
-
-
- MSBuild:Compile
- Designer
-
-
- AppConstants.cs
-
-
- Models\ConnectionInfo.cs
-
-
- Models\DeviceSetupOptions.cs
-
-
-
-
-
-
-
-
-
-
-
-
-
- MSBuild:Compile
- Designer
-
-
- App.xaml
- Code
-
-
- MainWindow.xaml
- Code
-
-
-
-
- Code
-
-
- True
- True
- Resources.resx
-
-
- True
- Settings.settings
- True
-
-
- ResXFileCodeGenerator
- Resources.Designer.cs
- Designer
-
-
-
- SettingsSingleFileGenerator
- Settings.Designer.cs
-
-
-
-
-
-
-
-
-
-
-
-
-
- {F935DC20-1CF0-11D0-ADB9-00C04FD58A0B}
- 1
- 0
- 0
- tlbimp
- False
- True
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Agent.Installer.Win/App.config b/Agent.Installer.Win/App.config
deleted file mode 100644
index 4bfa00561..000000000
--- a/Agent.Installer.Win/App.config
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/Agent.Installer.Win/App.xaml b/Agent.Installer.Win/App.xaml
deleted file mode 100644
index aa4b0db2f..000000000
--- a/Agent.Installer.Win/App.xaml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
diff --git a/Agent.Installer.Win/App.xaml.cs b/Agent.Installer.Win/App.xaml.cs
deleted file mode 100644
index 82a1a9d36..000000000
--- a/Agent.Installer.Win/App.xaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System.Windows;
-
-namespace Remotely.Agent.Installer.Win;
-
-///
-/// Interaction logic for App.xaml
-///
-public partial class App : Application
-{
-
-}
diff --git a/Agent.Installer.Win/MainWindow.xaml b/Agent.Installer.Win/MainWindow.xaml
deleted file mode 100644
index 43c3469a5..000000000
--- a/Agent.Installer.Win/MainWindow.xaml
+++ /dev/null
@@ -1,144 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Server URL:
-
-
-
-
-
-
-
-
- Organization ID
-
-
-
-
-
-
-
- Support Shortcut
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Agent.Installer.Win/MainWindow.xaml.cs b/Agent.Installer.Win/MainWindow.xaml.cs
deleted file mode 100644
index 9176cd2c5..000000000
--- a/Agent.Installer.Win/MainWindow.xaml.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using Remotely.Agent.Installer.Win.Utilities;
-using Remotely.Agent.Installer.Win.ViewModels;
-using System;
-using System.Windows;
-using System.Windows.Documents;
-using System.Windows.Input;
-
-namespace Remotely.Agent.Installer.Win;
-
-///
-/// Interaction logic for MainWindow.xaml
-///
-public partial class MainWindow : Window
-{
- public MainWindow()
- {
- if (CommandLineParser.CommandLineArgs.ContainsKey("quiet"))
- {
- Hide();
- ShowInTaskbar = false;
- _ = new MainWindowViewModel().Init();
- }
- InitializeComponent();
- }
-
- private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- DragMove();
- }
-
- private async void Window_Loaded(object sender, RoutedEventArgs e)
- {
- if (DataContext is MainWindowViewModel viewModel)
- {
- await viewModel.Init();
- }
- }
-
- private void CloseButton_Click(object sender, RoutedEventArgs e)
- {
- App.Current.Shutdown();
- }
-
- private void MinimizeButton_Click(object sender, RoutedEventArgs e)
- {
- this.WindowState = WindowState.Minimized;
- }
-
- private void ShowServerUrlHelp(object sender, RoutedEventArgs e)
- {
- MessageBox.Show(
- "This is the URL of the Remotely server that you're hosting. The device will connect to this URL.",
- "Server URL",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
- }
-
- private void ShowOrganizationIdHelp(object sender, RoutedEventArgs e)
- {
- MessageBox.Show(
- "This is your organization ID on the Remotely server. Since Remotely supports multi-tenancy, " +
- "this ID needs to be provided to determine who should have access."
- + Environment.NewLine + Environment.NewLine +
- "You can find this ID on the Organization tab on the web app.",
- "Organization ID",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
- }
- private void ShowSupportShortcutHelp(object sender, RoutedEventArgs e)
- {
- MessageBox.Show("If selected, the installer will create a desktop shortcut to the Get Support page for this device.",
- "Support Shortcut",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
- }
-}
diff --git a/Agent.Installer.Win/Models/BrandingInfo.cs b/Agent.Installer.Win/Models/BrandingInfo.cs
deleted file mode 100644
index 3d9d077fc..000000000
--- a/Agent.Installer.Win/Models/BrandingInfo.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-#nullable enable
-
-namespace Remotely.Agent.Installer.Win.Models;
-
-public class BrandingInfo
-{
- public string Product { get; set; } = "Remotely";
-
- public string? Icon { get; set; }
-}
diff --git a/Agent.Installer.Win/Models/EmbeddedServerData.cs b/Agent.Installer.Win/Models/EmbeddedServerData.cs
deleted file mode 100644
index e1eaaff46..000000000
--- a/Agent.Installer.Win/Models/EmbeddedServerData.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#nullable enable
-using System;
-using System.Runtime.Serialization;
-
-namespace Remotely.Agent.Installer.Models;
-
-[DataContract]
-public class EmbeddedServerData
-{
- ///
- /// Parameterless constructor for JsonSerializer.
- ///
- public EmbeddedServerData() { }
-
- public EmbeddedServerData(Uri serverUrl, string organizationId)
- {
- ServerUrl = serverUrl;
- OrganizationId = organizationId;
- }
-
- public static EmbeddedServerData Empty { get; } = new EmbeddedServerData();
-
- [DataMember]
- public string OrganizationId { get; set; } = string.Empty;
-
- [DataMember]
- public Uri? ServerUrl { get; set; }
-}
diff --git a/Agent.Installer.Win/Properties/AssemblyInfo.cs b/Agent.Installer.Win/Properties/AssemblyInfo.cs
deleted file mode 100644
index 08bc29a9b..000000000
--- a/Agent.Installer.Win/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Reflection;
-using System.Runtime.InteropServices;
-using System.Windows;
-
-// General Information about an assembly is controlled through the following
-// set of attributes. Change these attribute values to modify the information
-// associated with an assembly.
-[assembly: AssemblyTitle("Remotely Installer")]
-[assembly: AssemblyDescription("An installer for the Remotely service, which provides unattended remote access and remote scripting.")]
-[assembly: AssemblyConfiguration("")]
-[assembly: AssemblyCompany("Immense Networks")]
-[assembly: AssemblyProduct("Remotely Installer")]
-[assembly: AssemblyCopyright("Copyright © 2020 Immense Networks")]
-[assembly: AssemblyTrademark("")]
-[assembly: AssemblyCulture("")]
-
-// Setting ComVisible to false makes the types in this assembly not visible
-// to COM components. If you need to access a type in this assembly from
-// COM, set the ComVisible attribute to true on that type.
-[assembly: ComVisible(false)]
-
-//In order to begin building localizable applications, set
-//CultureYouAreCodingWith in your .csproj file
-//inside a . For example, if you are using US english
-//in your source files, set the to en-US. Then uncomment
-//the NeutralResourceLanguage attribute below. Update the "en-US" in
-//the line below to match the UICulture setting in the project file.
-
-//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)]
-
-
-[assembly: ThemeInfo(
- ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
- //(used if a resource is not found in the page,
- // or application resource dictionaries)
- ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
- //(used if a resource is not found in the page,
- // app, or any theme specific resource dictionaries)
-)]
-
-
-// Version information for an assembly consists of the following four values:
-//
-// Major Version
-// Minor Version
-// Build Number
-// Revision
-//
-// You can specify all the values or you can default the Build and Revision Numbers
-// by using the '*' as shown below:
-// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.0.0")]
-[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/Agent.Installer.Win/Properties/Resources.Designer.cs b/Agent.Installer.Win/Properties/Resources.Designer.cs
deleted file mode 100644
index fb5f7c6e5..000000000
--- a/Agent.Installer.Win/Properties/Resources.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Runtime Version:4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-//------------------------------------------------------------------------------
-
-namespace Remotely.Agent.Installer.Win.Properties {
- using System;
-
-
- ///
- /// A strongly-typed resource class, for looking up localized strings, etc.
- ///
- // This class was auto-generated by the StronglyTypedResourceBuilder
- // class via a tool like ResGen or Visual Studio.
- // To add or remove a member, edit your .ResX file then rerun ResGen
- // with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
- [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
- [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- internal class Resources {
-
- private static global::System.Resources.ResourceManager resourceMan;
-
- private static global::System.Globalization.CultureInfo resourceCulture;
-
- [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
- internal Resources() {
- }
-
- ///
- /// Returns the cached ResourceManager instance used by this class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Resources.ResourceManager ResourceManager {
- get {
- if (object.ReferenceEquals(resourceMan, null)) {
- global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Remotely.Agent.Installer.Win.Properties.Resources", typeof(Resources).Assembly);
- resourceMan = temp;
- }
- return resourceMan;
- }
- }
-
- ///
- /// Overrides the current thread's CurrentUICulture property for all
- /// resource lookups using this strongly typed resource class.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- internal static global::System.Globalization.CultureInfo Culture {
- get {
- return resourceCulture;
- }
- set {
- resourceCulture = value;
- }
- }
- }
-}
diff --git a/Agent.Installer.Win/Properties/Resources.resx b/Agent.Installer.Win/Properties/Resources.resx
deleted file mode 100644
index af7dbebba..000000000
--- a/Agent.Installer.Win/Properties/Resources.resx
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- text/microsoft-resx
-
-
- 2.0
-
-
- System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
- System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
\ No newline at end of file
diff --git a/Agent.Installer.Win/Properties/Settings.Designer.cs b/Agent.Installer.Win/Properties/Settings.Designer.cs
deleted file mode 100644
index 760c1573f..000000000
--- a/Agent.Installer.Win/Properties/Settings.Designer.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-//------------------------------------------------------------------------------
-//
-// This code was generated by a tool.
-// Runtime Version:4.0.30319.42000
-//
-// Changes to this file may cause incorrect behavior and will be lost if
-// the code is regenerated.
-//
-//------------------------------------------------------------------------------
-
-namespace Remotely.Agent.Installer.Win.Properties {
-
-
- [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.8.1.0")]
- internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
-
- private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
-
- public static Settings Default {
- get {
- return defaultInstance;
- }
- }
- }
-}
diff --git a/Agent.Installer.Win/Properties/Settings.settings b/Agent.Installer.Win/Properties/Settings.settings
deleted file mode 100644
index 033d7a5e9..000000000
--- a/Agent.Installer.Win/Properties/Settings.settings
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Agent.Installer.Win/Services/EmbeddedServerDataReader.cs b/Agent.Installer.Win/Services/EmbeddedServerDataReader.cs
deleted file mode 100644
index 42f55db71..000000000
--- a/Agent.Installer.Win/Services/EmbeddedServerDataReader.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-using System.Threading.Tasks;
-using System.Web.Script.Serialization;
-using Remotely.Agent.Installer.Models;
-using Remotely.Agent.Installer.Win.Utilities;
-
-namespace Remotely.Agent.Installer.Win.Services;
-
-internal class EmbeddedServerDataReader
-{
- private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer();
-
- public async Task TryGetEmbeddedData(string filePath)
- {
- try
- {
- if (!File.Exists(filePath))
- {
- throw new Exception($"File path does not exist: {filePath}");
- }
-
- using var fs = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- using var br = new BinaryReader(fs);
- using var sr = new StreamReader(fs);
-
- fs.Seek(-4, SeekOrigin.End);
- var dataSize = br.ReadInt32();
- fs.Seek(-dataSize - 4, SeekOrigin.End);
-
- if (dataSize == 0)
- {
- return EmbeddedServerData.Empty;
- }
-
- var buffer = new byte[dataSize];
- await fs.ReadAsync(buffer, 0, dataSize);
- var json = Encoding.UTF8.GetString(buffer);
-
- Logger.Write($"Extracted embedded data from EXE: {json}");
-
- var embeddedData = _serializer.Deserialize(json);
- if (embeddedData is not null)
- {
- return embeddedData;
- }
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
- return EmbeddedServerData.Empty;
- }
-}
diff --git a/Agent.Installer.Win/Services/InstallerService.cs b/Agent.Installer.Win/Services/InstallerService.cs
deleted file mode 100644
index d05b19360..000000000
--- a/Agent.Installer.Win/Services/InstallerService.cs
+++ /dev/null
@@ -1,455 +0,0 @@
-#nullable enable
-using IWshRuntimeLibrary;
-using Microsoft.VisualBasic.FileIO;
-using Microsoft.Win32;
-using Remotely.Agent.Installer.Win.Utilities;
-using Remotely.Shared.Models;
-using System;
-using System.Configuration.Install;
-using System.Diagnostics;
-using System.IO;
-using System.IO.Compression;
-using System.Linq;
-using System.Net;
-using System.Reflection;
-using System.Security.Principal;
-using System.ServiceProcess;
-using System.Threading.Tasks;
-using System.Web.Script.Serialization;
-using System.Windows;
-using FileIO = System.IO.File;
-
-namespace Remotely.Agent.Installer.Win.Services;
-
-public class InstallerService
-{
- private readonly string _installPath = Path.Combine(Path.GetPathRoot(Environment.SystemDirectory), "Program Files", "Remotely");
- private readonly string _platform = Environment.Is64BitOperatingSystem ? "x64" : "x86";
- private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer();
-
- public event EventHandler? ProgressMessageChanged;
- public event EventHandler? ProgressValueChanged;
-
- public async Task Install(string serverUrl,
- string organizationId,
- string? deviceGroup,
- string? deviceAlias,
- string? deviceUuid,
- bool createSupportShortcut)
- {
- try
- {
- Logger.Write("Install started.");
- if (!CheckIsAdministrator())
- {
- return false;
- }
-
- StopService();
-
- await StopProcesses();
-
- BackupDirectory();
-
- var connectionInfo = GetConnectionInfo(organizationId, serverUrl, deviceUuid);
-
- ClearInstallDirectory();
-
- await DownloadRemotelyAgent(serverUrl);
-
- FileIO.WriteAllText(Path.Combine(_installPath, "ConnectionInfo.json"), _serializer.Serialize(connectionInfo));
-
- FileIO.Copy(Assembly.GetExecutingAssembly().Location, Path.Combine(_installPath, "Remotely_Installer.exe"));
-
- await CreateDeviceOnServer(connectionInfo.DeviceID, serverUrl, deviceGroup, deviceAlias, organizationId);
-
- AddFirewallRule();
-
- InstallService();
-
- CreateUninstallKey();
-
- CreateSupportShortcut(serverUrl, connectionInfo.DeviceID, createSupportShortcut);
-
- return true;
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- RestoreBackup();
- return false;
- }
-
- }
-
- public async Task Uninstall()
- {
- try
- {
- if (!CheckIsAdministrator())
- {
- return false;
- }
-
- StopService();
-
- ProcessEx.StartHidden("cmd.exe", "/c sc delete Remotely_Service").WaitForExit();
-
- await StopProcesses();
-
- ProgressMessageChanged?.Invoke(this, "Deleting files.");
- ClearInstallDirectory();
- ProcessEx.StartHidden("cmd.exe", $"/c timeout 5 & rd /s /q \"{_installPath}\"");
-
- ProcessEx.StartHidden("netsh", "advfirewall firewall delete rule name=\"Remotely Desktop Unattended\"").WaitForExit();
-
- GetRegistryBaseKey().DeleteSubKeyTree(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Remotely", false);
-
- return true;
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- return false;
- }
- }
-
- private void AddFirewallRule()
- {
- var desktopExePath = Path.Combine(_installPath, "Desktop", "Remotely_Desktop.exe");
- ProcessEx.StartHidden("netsh", "advfirewall firewall delete rule name=\"Remotely Desktop Unattended\"").WaitForExit();
- ProcessEx.StartHidden("netsh", $"advfirewall firewall add rule name=\"Remotely Desktop Unattended\" program=\"{desktopExePath}\" protocol=any dir=in enable=yes action=allow description=\"The agent that allows screen sharing and remote control for Remotely.\"").WaitForExit();
- }
-
- private void BackupDirectory()
- {
- if (Directory.Exists(_installPath))
- {
- Logger.Write("Backing up current installation.");
- ProgressMessageChanged?.Invoke(this, "Backing up current installation.");
- var backupPath = Path.Combine(Path.GetTempPath(), "Remotely_Backup.zip");
- if (FileIO.Exists(backupPath))
- {
- FileIO.Delete(backupPath);
- }
- ZipFile.CreateFromDirectory(_installPath, backupPath, CompressionLevel.Fastest, false);
- }
- }
-
- private bool CheckIsAdministrator()
- {
- var identity = WindowsIdentity.GetCurrent();
- var principal = new WindowsPrincipal(identity);
- var result = principal.IsInRole(WindowsBuiltInRole.Administrator);
- if (!result)
- {
- MessageBoxEx.Show("Elevated privileges are required. Please restart the installer using 'Run as administrator'.", "Elevation Required", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
- return result;
- }
-
- private void ClearInstallDirectory()
- {
- if (Directory.Exists(_installPath))
- {
- foreach (var entry in Directory.GetFileSystemEntries(_installPath))
- {
- try
- {
- if (FileIO.Exists(entry))
- {
- FileIO.Delete(entry);
- }
- else if (Directory.Exists(entry))
- {
- Directory.Delete(entry, true);
- }
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
- }
- }
- }
-
- private async Task CreateDeviceOnServer(string deviceUuid,
- string serverUrl,
- string? deviceGroup,
- string? deviceAlias,
- string organizationId)
- {
- try
- {
- if (!string.IsNullOrWhiteSpace(deviceGroup) ||
- !string.IsNullOrWhiteSpace(deviceAlias))
- {
- var setupOptions = new DeviceSetupOptions()
- {
- DeviceID = deviceUuid,
- DeviceGroupName = deviceGroup,
- DeviceAlias = deviceAlias,
- OrganizationID = organizationId
- };
-
- var wr = WebRequest.CreateHttp(serverUrl.TrimEnd('/') + "/api/devices");
- wr.Method = "POST";
- wr.ContentType = "application/json";
- using (var rs = await wr.GetRequestStreamAsync())
- using (var sw = new StreamWriter(rs))
- {
- await sw.WriteAsync(_serializer.Serialize(setupOptions));
- }
- using var response = await wr.GetResponseAsync();
- if (response is HttpWebResponse httpResponse)
- {
- Logger.Write($"Create device response: {httpResponse.StatusCode}");
- }
- }
- }
- catch (WebException ex) when ((ex.Response is HttpWebResponse response) && response.StatusCode == HttpStatusCode.BadRequest)
- {
- Logger.Write("Bad request when creating device. The device ID may already be created.");
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
-
- }
-
- private void CreateSupportShortcut(string serverUrl, string deviceUuid, bool createSupportShortcut)
- {
- var shell = new WshShell();
- var shortcutLocation = Path.Combine(_installPath, "Get Support.lnk");
- var shortcut = (IWshShortcut)shell.CreateShortcut(shortcutLocation);
- shortcut.Description = "Get IT support";
- shortcut.IconLocation = Path.Combine(_installPath, "Remotely_Agent.exe");
- shortcut.TargetPath = serverUrl.TrimEnd('/') + $"/get-support?deviceID={deviceUuid}";
- shortcut.Save();
-
- if (createSupportShortcut)
- {
- var systemRoot = Path.GetPathRoot(Environment.SystemDirectory);
- var publicDesktop = Path.Combine(systemRoot, "Users", "Public", "Desktop", "Get Support.lnk");
- FileIO.Copy(shortcutLocation, publicDesktop, true);
- }
- }
- private void CreateUninstallKey()
- {
- var version = FileVersionInfo.GetVersionInfo(Path.Combine(_installPath, "Remotely_Agent.exe"));
- var baseKey = GetRegistryBaseKey();
-
- var remotelyKey = baseKey.CreateSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Remotely", true);
- remotelyKey.SetValue("DisplayIcon", Path.Combine(_installPath, "Remotely_Agent.exe"));
- remotelyKey.SetValue("DisplayName", "Remotely");
- remotelyKey.SetValue("DisplayVersion", version.FileVersion);
- remotelyKey.SetValue("InstallDate", DateTime.Now.ToShortDateString());
- remotelyKey.SetValue("Publisher", "Immense Networks");
- remotelyKey.SetValue("VersionMajor", version.FileMajorPart.ToString(), RegistryValueKind.DWord);
- remotelyKey.SetValue("VersionMinor", version.FileMinorPart.ToString(), RegistryValueKind.DWord);
- remotelyKey.SetValue("UninstallString", Path.Combine(_installPath, "Remotely_Installer.exe -uninstall -quiet"));
- remotelyKey.SetValue("QuietUninstallString", Path.Combine(_installPath, "Remotely_Installer.exe -uninstall -quiet"));
- }
-
- private async Task DownloadRemotelyAgent(string serverUrl)
- {
- var targetFile = Path.Combine(Path.GetTempPath(), $"Remotely-Agent.zip");
-
- if (CommandLineParser.CommandLineArgs.TryGetValue("path", out var result) &&
- FileIO.Exists(result))
- {
- targetFile = result;
- }
- else
- {
- ProgressMessageChanged?.Invoke(this, "Downloading Remotely agent.");
- using (var client = new WebClient())
- {
- client.DownloadProgressChanged += (sender, args) =>
- {
- ProgressValueChanged?.Invoke(this, args.ProgressPercentage);
- };
-
- await client.DownloadFileTaskAsync($"{serverUrl}/Content/Remotely-Win-{_platform}.zip", targetFile);
- }
- }
-
- ProgressMessageChanged?.Invoke(this, "Extracting Remotely files.");
- ProgressValueChanged?.Invoke(this, 0);
-
- var tempDir = Path.Combine(Path.GetTempPath(), "RemotelyUpdate");
- if (Directory.Exists(tempDir))
- {
- Directory.Delete(tempDir, true);
- }
-
- Directory.CreateDirectory(_installPath);
- while (!Directory.Exists(_installPath))
- {
- await Task.Delay(10);
- }
-
- var wr = WebRequest.CreateHttp($"{serverUrl}/Content/Remotely-Win-{_platform}.zip");
- wr.Method = "Head";
- using (var response = (HttpWebResponse)await wr.GetResponseAsync())
- {
- FileIO.WriteAllText(Path.Combine(_installPath, "etag.txt"), response.Headers["ETag"]);
- }
-
- ZipFile.ExtractToDirectory(targetFile, tempDir);
- var fileSystemEntries = Directory.GetFileSystemEntries(tempDir);
- for (var i = 0; i < fileSystemEntries.Length; i++)
- {
- try
- {
- ProgressValueChanged?.Invoke(this, (int)((double)i / (double)fileSystemEntries.Length * 100d));
- var entry = fileSystemEntries[i];
- if (FileIO.Exists(entry))
- {
- FileIO.Copy(entry, Path.Combine(_installPath, Path.GetFileName(entry)), true);
- }
- else if (Directory.Exists(entry))
- {
- FileSystem.CopyDirectory(entry, Path.Combine(_installPath, new DirectoryInfo(entry).Name), true);
- }
- await Task.Delay(1);
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
- }
- ProgressValueChanged?.Invoke(this, 0);
- }
-
- private ConnectionInfo GetConnectionInfo(string organizationId, string serverUrl, string? deviceUuid)
- {
- ConnectionInfo connectionInfo;
- var connectionInfoPath = Path.Combine(_installPath, "ConnectionInfo.json");
- if (FileIO.Exists(connectionInfoPath))
- {
- connectionInfo = _serializer.Deserialize(FileIO.ReadAllText(connectionInfoPath));
- connectionInfo.ServerVerificationToken = null;
- }
- else
- {
- connectionInfo = new ConnectionInfo()
- {
- DeviceID = Guid.NewGuid().ToString()
- };
- }
-
- if (!string.IsNullOrWhiteSpace(deviceUuid))
- {
- // Clear the server verification token if we're installing this as a new device.
- if (connectionInfo.DeviceID != deviceUuid)
- {
- connectionInfo.ServerVerificationToken = null;
- }
- connectionInfo.DeviceID = deviceUuid!;
- }
- connectionInfo.OrganizationID = organizationId;
- connectionInfo.Host = serverUrl;
- return connectionInfo;
- }
-
- private RegistryKey GetRegistryBaseKey()
- {
- if (Environment.Is64BitOperatingSystem)
- {
- return RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
- }
- else
- {
- return RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
- }
- }
-
- private void InstallService()
- {
- Logger.Write("Installing service.");
- ProgressMessageChanged?.Invoke(this, "Installing Remotely service.");
- var serv = ServiceController.GetServices().FirstOrDefault(ser => ser.ServiceName == "Remotely_Service");
- if (serv == null)
- {
- var command = new string[] { "/assemblypath=" + Path.Combine(_installPath, "Remotely_Agent.exe") };
- var context = new InstallContext("", command);
- var serviceInstaller = new ServiceInstaller()
- {
- Context = context,
- DisplayName = "Remotely Service",
- Description = "Background service that maintains a connection to the Remotely server. The service is used for remote support and maintenance by this computer's administrators.",
- ServiceName = "Remotely_Service",
- StartType = ServiceStartMode.Automatic,
- Parent = new ServiceProcessInstaller()
- };
-
- var state = new System.Collections.Specialized.ListDictionary();
- serviceInstaller.Install(state);
- Logger.Write("Service installed.");
- serv = ServiceController.GetServices().FirstOrDefault(ser => ser.ServiceName == "Remotely_Service");
-
- ProcessEx.StartHidden("cmd.exe", "/c sc.exe failure \"Remotely_Service\" reset= 5 actions= restart/5000");
- }
- if (serv.Status != ServiceControllerStatus.Running)
- {
- serv.Start();
- }
- Logger.Write("Service started.");
- }
-
- private void RestoreBackup()
- {
- try
- {
- var backupPath = Path.Combine(Path.GetTempPath(), "Remotely_Backup.zip");
- if (FileIO.Exists(backupPath))
- {
- Logger.Write("Restoring backup.");
- ClearInstallDirectory();
- ZipFile.ExtractToDirectory(backupPath, _installPath);
- var serv = ServiceController.GetServices().FirstOrDefault(ser => ser.ServiceName == "Remotely_Service");
- if (serv?.Status != ServiceControllerStatus.Running)
- {
- serv?.Start();
- }
- }
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
- }
-
- private async Task StopProcesses()
- {
- ProgressMessageChanged?.Invoke(this, "Stopping Remotely processes.");
- var procs = Process.GetProcessesByName("Remotely_Agent").Concat(Process.GetProcessesByName("Remotely_Desktop"));
-
- foreach (var proc in procs)
- {
- proc.Kill();
- }
-
- await Task.Delay(500);
- }
- private void StopService()
- {
- try
- {
- var remotelyService = ServiceController.GetServices().FirstOrDefault(x => x.ServiceName == "Remotely_Service");
- if (remotelyService != null)
- {
- Logger.Write("Stopping existing Remotely service.");
- ProgressMessageChanged?.Invoke(this, "Stopping existing Remotely service.");
- remotelyService.Stop();
- remotelyService.WaitForStatus(ServiceControllerStatus.Stopped);
- }
- }
- catch (Exception ex)
- {
- Logger.Write(ex);
- }
- }
-}
diff --git a/Agent.Installer.Win/Services/RelayCommand.cs b/Agent.Installer.Win/Services/RelayCommand.cs
deleted file mode 100644
index 112042d79..000000000
--- a/Agent.Installer.Win/Services/RelayCommand.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-#nullable enable
-
-using System;
-using System.Windows.Input;
-
-namespace Remotely.Agent.Installer.Win.Services;
-
-public class RelayCommand : ICommand
-{
- private readonly Action
@@ -56,8 +56,6 @@
-
-
-
+
diff --git a/Desktop.Linux/Program.cs b/Desktop.Linux/Program.cs
index 72ee7948f..7c04131a7 100644
--- a/Desktop.Linux/Program.cs
+++ b/Desktop.Linux/Program.cs
@@ -1,19 +1,16 @@
-using Immense.RemoteControl.Desktop.Shared.Abstractions;
-using System.Threading.Tasks;
+using Remotely.Desktop.Shared.Abstractions;
using System.Threading;
-using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Remotely.Shared.Services;
-using Immense.RemoteControl.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.Services;
using System.Diagnostics;
using Remotely.Shared.Utilities;
-using Immense.RemoteControl.Desktop.Shared.Startup;
-using System.Linq;
-using Immense.RemoteControl.Desktop.Linux.Startup;
-using Immense.RemoteControl.Desktop.UI.Services;
+using Remotely.Desktop.Shared.Startup;
+using Remotely.Desktop.Linux.Startup;
+using Remotely.Desktop.UI.Services;
using Avalonia;
-using Immense.RemoteControl.Desktop.UI;
+using Remotely.Desktop.UI;
using Desktop.Shared.Services;
namespace Remotely.Desktop.XPlat;
@@ -45,14 +42,8 @@ public static async Task Main(string[] args)
var services = new ServiceCollection();
- services.AddSingleton();
services.AddSingleton();
-
- services.AddRemoteControlLinux(
- config =>
- {
- config.AddBrandingProvider();
- });
+ services.AddRemoteControlLinux();
services.AddLogging(builder =>
{
@@ -66,17 +57,16 @@ public static async Task Main(string[] args)
var provider = services.BuildServiceProvider();
var appState = provider.GetRequiredService();
- var orgIdProvider = provider.GetRequiredService();
if (getEmbeddedResult.IsSuccess)
{
- orgIdProvider.OrganizationId = getEmbeddedResult.Value.OrganizationId;
+ appState.OrganizationId = getEmbeddedResult.Value.OrganizationId;
appState.Host = getEmbeddedResult.Value.ServerUrl.AbsoluteUri;
}
if (appState.ArgDict.TryGetValue("org-id", out var orgId))
{
- orgIdProvider.OrganizationId = orgId;
+ appState.OrganizationId = orgId;
}
var result = await provider.UseRemoteControlClient(
diff --git a/Desktop.Linux/Services/AppStartup.cs b/Desktop.Linux/Services/AppStartup.cs
new file mode 100644
index 000000000..5695f9f51
--- /dev/null
+++ b/Desktop.Linux/Services/AppStartup.cs
@@ -0,0 +1,151 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.UI.Services;
+using Remotely.Shared.Models;
+using Microsoft.Extensions.Logging;
+using Desktop.Shared.Services;
+
+namespace Remotely.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 _logger;
+
+ public AppStartup(
+ IAppState appState,
+ IKeyboardMouseInput inputService,
+ IDesktopHubConnection desktopHub,
+ IClipboardService clipboardService,
+ IChatHostService chatHostService,
+ ICursorIconWatcher iconWatcher,
+ IUiDispatcher dispatcher,
+ IIdleTimer idleTimer,
+ IShutdownService shutdownService,
+ IBrandingProvider brandingProvider,
+ ILogger 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);
+ }
+ }
+ }
+}
diff --git a/Desktop.Linux/Services/AudioCapturerLinux.cs b/Desktop.Linux/Services/AudioCapturerLinux.cs
new file mode 100644
index 000000000..ae7e7603e
--- /dev/null
+++ b/Desktop.Linux/Services/AudioCapturerLinux.cs
@@ -0,0 +1,15 @@
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class AudioCapturerLinux : IAudioCapturer
+{
+#pragma warning disable CS0067
+ public event EventHandler? AudioSampleReady;
+#pragma warning restore
+
+ public void ToggleAudio(bool toggleOn)
+ {
+ // Not implemented.
+ }
+}
diff --git a/Desktop.Linux/Services/CursorIconWatcherLinux.cs b/Desktop.Linux/Services/CursorIconWatcherLinux.cs
new file mode 100644
index 000000000..6cb1105c8
--- /dev/null
+++ b/Desktop.Linux/Services/CursorIconWatcherLinux.cs
@@ -0,0 +1,15 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Models;
+using System.Drawing;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class CursorIconWatcherLinux : ICursorIconWatcher
+{
+#pragma warning disable CS0067
+ public event EventHandler? OnChange;
+#pragma warning restore
+
+
+ public CursorInfo GetCurrentCursor() => new(Array.Empty(), Point.Empty, "default");
+}
diff --git a/Desktop.Linux/Services/FileTransferServiceLinux.cs b/Desktop.Linux/Services/FileTransferServiceLinux.cs
new file mode 100644
index 000000000..ac88c5083
--- /dev/null
+++ b/Desktop.Linux/Services/FileTransferServiceLinux.cs
@@ -0,0 +1,160 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.ViewModels;
+using Microsoft.Extensions.Logging;
+using System.Collections.Concurrent;
+using Remotely.Desktop.UI.Controls.Dialogs;
+using Remotely.Desktop.UI.Views;
+using Remotely.Desktop.UI.Services;
+using System.Threading;
+using System.IO;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class FileTransferServiceLinux : IFileTransferService
+{
+ private static readonly ConcurrentDictionary _fileTransferWindows = new();
+ private static readonly ConcurrentDictionary _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 _logger;
+
+ public FileTransferServiceLinux(
+ IViewModelFactory viewModelFactory,
+ IUiDispatcher dispatcher,
+ IDialogProvider dialogProvider,
+ ILogger 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 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;
+ }
+ }
+}
diff --git a/Desktop.Linux/Services/KeyboardMouseInputLinux.cs b/Desktop.Linux/Services/KeyboardMouseInputLinux.cs
new file mode 100644
index 000000000..fec58f4a5
--- /dev/null
+++ b/Desktop.Linux/Services/KeyboardMouseInputLinux.cs
@@ -0,0 +1,259 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Native.Linux;
+using Remotely.Desktop.Shared.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class KeyboardMouseInputLinux : IKeyboardMouseInput
+{
+ private readonly ILogger _logger;
+
+ private nint Display { get; set; }
+
+ public KeyboardMouseInputLinux(ILogger 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.");
+ }
+ }
+
+}
diff --git a/Desktop.Linux/Services/ScreenCapturerLinux.cs b/Desktop.Linux/Services/ScreenCapturerLinux.cs
new file mode 100644
index 000000000..63d028e11
--- /dev/null
+++ b/Desktop.Linux/Services/ScreenCapturerLinux.cs
@@ -0,0 +1,239 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Native.Linux;
+using Remotely.Desktop.Shared.Services;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Primitives;
+using SkiaSharp;
+using System.Drawing;
+using System.Runtime.InteropServices;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class ScreenCapturerLinux : IScreenCapturer
+{
+ private readonly IImageHelper _imageHelper;
+ private readonly ILogger _logger;
+ private readonly object _screenBoundsLock = new();
+ private readonly Dictionary _x11Screens = new();
+ private SKBitmap? _currentFrame;
+ private SKBitmap? _previousFrame;
+
+ public ScreenCapturerLinux(
+ IImageHelper imageHelper,
+ ILogger logger)
+ {
+ _imageHelper = imageHelper;
+ _logger = logger;
+ Display = LibX11.XOpenDisplay(string.Empty);
+ Init();
+ }
+
+ public event EventHandler? 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 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 GetImageDiff()
+ {
+ if (_currentFrame is null)
+ {
+ return Result.Fail("Current frame is null.");
+ }
+
+ return _imageHelper.GetImageDiff(_currentFrame, _previousFrame);
+ }
+
+ public Result 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(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();
+
+ for (var i = 0; i < monitorCount; i++)
+ {
+ var monitorPtr = new nint(monitorsPtr.ToInt64() + i * monitorInfoSize);
+ var monitorInfo = Marshal.PtrToStructure(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(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(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);
+ }
+}
diff --git a/Desktop.Linux/Services/ShutdownServiceLinux.cs b/Desktop.Linux/Services/ShutdownServiceLinux.cs
new file mode 100644
index 000000000..2c28661bd
--- /dev/null
+++ b/Desktop.Linux/Services/ShutdownServiceLinux.cs
@@ -0,0 +1,49 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.UI.Services;
+
+namespace Remotely.Desktop.Linux.Services;
+
+public class ShutdownServiceLinux : IShutdownService
+{
+ private readonly IDesktopHubConnection _hubConnection;
+ private readonly IUiDispatcher _dispatcher;
+ private readonly IAppState _appState;
+ private readonly ILogger _logger;
+
+ public ShutdownServiceLinux(
+ IDesktopHubConnection hubConnection,
+ IUiDispatcher dispatcher,
+ IAppState appState,
+ ILogger 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.");
+ }
+ }
+}
diff --git a/Desktop.Linux/Startup/IServiceCollectionExtensions.cs b/Desktop.Linux/Startup/IServiceCollectionExtensions.cs
new file mode 100644
index 000000000..da9d6fe08
--- /dev/null
+++ b/Desktop.Linux/Startup/IServiceCollectionExtensions.cs
@@ -0,0 +1,35 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Startup;
+using Microsoft.Extensions.DependencyInjection;
+using Remotely.Desktop.Linux.Services;
+using Remotely.Desktop.UI.ViewModels;
+using Remotely.Desktop.UI.Services;
+using Remotely.Desktop.UI.Startup;
+
+namespace Remotely.Desktop.Linux.Startup;
+
+public static class IServiceCollectionExtensions
+{
+ ///
+ /// Adds Linux and cross-platform remote control services to the service collection.
+ /// All methods on must be called to register
+ /// required services.
+ ///
+ ///
+ ///
+ public static void AddRemoteControlLinux(this IServiceCollection services)
+ {
+ services.AddRemoteControlXplat();
+ services.AddRemoteControlUi();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddTransient();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+}
diff --git a/Desktop.Linux/Usings.cs b/Desktop.Linux/Usings.cs
new file mode 100644
index 000000000..c8962ecf9
--- /dev/null
+++ b/Desktop.Linux/Usings.cs
@@ -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;
diff --git a/Desktop.Native/Desktop.Native.csproj b/Desktop.Native/Desktop.Native.csproj
new file mode 100644
index 000000000..84d1cca2b
--- /dev/null
+++ b/Desktop.Native/Desktop.Native.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+ True
+
+
+
+
+
+
+
diff --git a/Desktop.Native/Linux/LibX11.cs b/Desktop.Native/Linux/LibX11.cs
new file mode 100644
index 000000000..cc16107f2
--- /dev/null
+++ b/Desktop.Native/Linux/LibX11.cs
@@ -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 Remotely.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;
+ }
+}
diff --git a/Desktop.Native/Linux/LibXtst.cs b/Desktop.Native/Linux/LibXtst.cs
new file mode 100644
index 000000000..462691974
--- /dev/null
+++ b/Desktop.Native/Linux/LibXtst.cs
@@ -0,0 +1,15 @@
+using System.Runtime.InteropServices;
+
+namespace Remotely.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);
+}
diff --git a/Desktop.Native/Linux/Libc.cs b/Desktop.Native/Linux/Libc.cs
new file mode 100644
index 000000000..bd7abccbd
--- /dev/null
+++ b/Desktop.Native/Linux/Libc.cs
@@ -0,0 +1,9 @@
+using System.Runtime.InteropServices;
+
+namespace Remotely.Desktop.Shared.Native.Linux;
+
+public class Libc
+{
+ [DllImport("libc", SetLastError = true)]
+ public static extern uint geteuid();
+}
diff --git a/Desktop.Native/Linux/libXrandr.cs b/Desktop.Native/Linux/libXrandr.cs
new file mode 100644
index 000000000..27ce7fdd5
--- /dev/null
+++ b/Desktop.Native/Linux/libXrandr.cs
@@ -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 Remotely.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);
+}
diff --git a/Desktop.Native/Windows/ADVAPI32.cs b/Desktop.Native/Windows/ADVAPI32.cs
new file mode 100644
index 000000000..8e8c661b2
--- /dev/null
+++ b/Desktop.Native/Windows/ADVAPI32.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Security;
+
+namespace Remotely.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
+ {
+ ///
+ /// The buffer receives a TOKEN_USER structure that contains the user account of the token.
+ ///
+ TokenUser = 1,
+
+ ///
+ /// The buffer receives a TOKEN_GROUPS structure that contains the group accounts associated with the token.
+ ///
+ TokenGroups,
+
+ ///
+ /// The buffer receives a TOKEN_PRIVILEGES structure that contains the privileges of the token.
+ ///
+ TokenPrivileges,
+
+ ///
+ /// The buffer receives a TOKEN_OWNER structure that contains the default owner security identifier (SID) for newly created objects.
+ ///
+ TokenOwner,
+
+ ///
+ /// The buffer receives a TOKEN_PRIMARY_GROUP structure that contains the default primary group SID for newly created objects.
+ ///
+ TokenPrimaryGroup,
+
+ ///
+ /// The buffer receives a TOKEN_DEFAULT_DACL structure that contains the default DACL for newly created objects.
+ ///
+ TokenDefaultDacl,
+
+ ///
+ /// The buffer receives a TOKEN_SOURCE structure that contains the source of the token. TOKEN_QUERY_SOURCE access is needed to retrieve this information.
+ ///
+ TokenSource,
+
+ ///
+ /// The buffer receives a TOKEN_TYPE value that indicates whether the token is a primary or impersonation token.
+ ///
+ TokenType,
+
+ ///
+ /// 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.
+ ///
+ TokenImpersonationLevel,
+
+ ///
+ /// The buffer receives a TOKEN_STATISTICS structure that contains various token statistics.
+ ///
+ TokenStatistics,
+
+ ///
+ /// The buffer receives a TOKEN_GROUPS structure that contains the list of restricting SIDs in a restricted token.
+ ///
+ TokenRestrictedSids,
+
+ ///
+ /// The buffer receives a DWORD value that indicates the Terminal Services session identifier that is associated with the token.
+ ///
+ TokenSessionId,
+
+ ///
+ /// 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.
+ ///
+ TokenGroupsAndPrivileges,
+
+ ///
+ /// Reserved.
+ ///
+ TokenSessionReference,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token includes the SANDBOX_INERT flag.
+ ///
+ TokenSandBoxInert,
+
+ ///
+ /// Reserved.
+ ///
+ TokenAuditPolicy,
+
+ ///
+ /// The buffer receives a TOKEN_ORIGIN value.
+ ///
+ TokenOrigin,
+
+ ///
+ /// The buffer receives a TOKEN_ELEVATION_TYPE value that specifies the elevation level of the token.
+ ///
+ TokenElevationType,
+
+ ///
+ /// The buffer receives a TOKEN_LINKED_TOKEN structure that contains a handle to another token that is linked to this token.
+ ///
+ TokenLinkedToken,
+
+ ///
+ /// The buffer receives a TOKEN_ELEVATION structure that specifies whether the token is elevated.
+ ///
+ TokenElevation,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token has ever been filtered.
+ ///
+ TokenHasRestrictions,
+
+ ///
+ /// The buffer receives a TOKEN_ACCESS_INFORMATION structure that specifies security information contained in the token.
+ ///
+ TokenAccessInformation,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if virtualization is allowed for the token.
+ ///
+ TokenVirtualizationAllowed,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if virtualization is enabled for the token.
+ ///
+ TokenVirtualizationEnabled,
+
+ ///
+ /// The buffer receives a TOKEN_MANDATORY_LABEL structure that specifies the token's integrity level.
+ ///
+ TokenIntegrityLevel,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token has the UIAccess flag set.
+ ///
+ TokenUIAccess,
+
+ ///
+ /// The buffer receives a TOKEN_MANDATORY_POLICY structure that specifies the token's mandatory integrity policy.
+ ///
+ TokenMandatoryPolicy,
+
+ ///
+ /// The buffer receives the token's logon security identifier (SID).
+ ///
+ TokenLogonSid,
+
+ ///
+ /// The maximum value for this enumeration
+ ///
+ 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
+}
diff --git a/Desktop.Native/Windows/GDI32.cs b/Desktop.Native/Windows/GDI32.cs
new file mode 100644
index 000000000..44526e4df
--- /dev/null
+++ b/Desktop.Native/Windows/GDI32.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Remotely.Desktop.Shared.Native.Windows;
+
+public static class GDI32
+{
+ #region Enums
+ ///
+ /// 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.
+ ///
+ public enum TernaryRasterOperations : uint
+ {
+ /// dest = source
+ SRCCOPY = 0x00CC0020,
+ /// dest = source OR dest
+ SRCPAINT = 0x00EE0086,
+ /// dest = source AND dest
+ SRCAND = 0x008800C6,
+ /// dest = source XOR dest
+ SRCINVERT = 0x00660046,
+ /// dest = source AND (NOT dest)
+ SRCERASE = 0x00440328,
+ /// dest = (NOT source)
+ NOTSRCCOPY = 0x00330008,
+ /// dest = (NOT src) AND (NOT dest)
+ NOTSRCERASE = 0x001100A6,
+ /// dest = (source AND pattern)
+ MERGECOPY = 0x00C000CA,
+ /// dest = (NOT source) OR dest
+ MERGEPAINT = 0x00BB0226,
+ /// dest = pattern
+ PATCOPY = 0x00F00021,
+ /// dest = DPSnoo
+ PATPAINT = 0x00FB0A09,
+ /// dest = pattern XOR dest
+ PATINVERT = 0x005A0049,
+ /// dest = (NOT dest)
+ DSTINVERT = 0x00550009,
+ /// dest = BLACK
+ BLACKNESS = 0x00000042,
+ /// dest = WHITE
+ WHITENESS = 0x00FF0062,
+ ///
+ /// Capture window as seen on screen. This includes layered windows
+ /// such as WPF windows with AllowsTransparency="true"
+ ///
+ 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
+}
diff --git a/Desktop.Native/Windows/Kernel32.cs b/Desktop.Native/Windows/Kernel32.cs
new file mode 100644
index 000000000..410f6b46f
--- /dev/null
+++ b/Desktop.Native/Windows/Kernel32.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Remotely.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();
+
+ ///
+ /// contains information about the current state of both physical and virtual memory, including extended memory
+ ///
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+ public class MEMORYSTATUSEX
+ {
+ ///
+ /// Size of the structure, in bytes. You must set this member before calling GlobalMemoryStatusEx.
+ ///
+ public uint dwLength;
+
+ ///
+ /// 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).
+ ///
+ public uint dwMemoryLoad;
+
+ ///
+ /// Total size of physical memory, in bytes.
+ ///
+ public ulong ullTotalPhys;
+
+ ///
+ /// Size of physical memory available, in bytes.
+ ///
+ public ulong ullAvailPhys;
+
+ ///
+ /// Size of the committed memory limit, in bytes. This is physical memory plus the size of the page file, minus a small overhead.
+ ///
+ public ulong ullTotalPageFile;
+
+ ///
+ /// Size of available memory to commit, in bytes. The limit is ullTotalPageFile.
+ ///
+ public ulong ullAvailPageFile;
+
+ ///
+ /// Total size of the user mode portion of the virtual address space of the calling process, in bytes.
+ ///
+ public ulong ullTotalVirtual;
+
+ ///
+ /// Size of unreserved and uncommitted memory in the user mode portion of the virtual address space of the calling process, in bytes.
+ ///
+ public ulong ullAvailVirtual;
+
+ ///
+ /// Size of unreserved and uncommitted memory in the extended portion of the virtual address space of the calling process, in bytes.
+ ///
+ public ulong ullAvailExtendedVirtual;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MEMORYSTATUSEX()
+ {
+ dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
+ }
+ }
+}
diff --git a/Desktop.Native/Windows/SECUR32.cs b/Desktop.Native/Windows/SECUR32.cs
new file mode 100644
index 000000000..2f6466327
--- /dev/null
+++ b/Desktop.Native/Windows/SECUR32.cs
@@ -0,0 +1,376 @@
+using Remotely.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
+ {
+ ///
+ /// The buffer receives a TOKEN_USER structure that contains the user account of the token.
+ ///
+ TokenUser = 1,
+
+ ///
+ /// The buffer receives a TOKEN_GROUPS structure that contains the group accounts associated with the token.
+ ///
+ TokenGroups,
+
+ ///
+ /// The buffer receives a TOKEN_PRIVILEGES structure that contains the privileges of the token.
+ ///
+ TokenPrivileges,
+
+ ///
+ /// The buffer receives a TOKEN_OWNER structure that contains the default owner security identifier (SID) for newly created objects.
+ ///
+ TokenOwner,
+
+ ///
+ /// The buffer receives a TOKEN_PRIMARY_GROUP structure that contains the default primary group SID for newly created objects.
+ ///
+ TokenPrimaryGroup,
+
+ ///
+ /// The buffer receives a TOKEN_DEFAULT_DACL structure that contains the default DACL for newly created objects.
+ ///
+ TokenDefaultDacl,
+
+ ///
+ /// The buffer receives a TOKEN_SOURCE structure that contains the source of the token. TOKEN_QUERY_SOURCE access is needed to retrieve this information.
+ ///
+ TokenSource,
+
+ ///
+ /// The buffer receives a TOKEN_TYPE value that indicates whether the token is a primary or impersonation token.
+ ///
+ TokenType,
+
+ ///
+ /// 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.
+ ///
+ TokenImpersonationLevel,
+
+ ///
+ /// The buffer receives a TOKEN_STATISTICS structure that contains various token statistics.
+ ///
+ TokenStatistics,
+
+ ///
+ /// The buffer receives a TOKEN_GROUPS structure that contains the list of restricting SIDs in a restricted token.
+ ///
+ TokenRestrictedSids,
+
+ ///
+ /// The buffer receives a DWORD value that indicates the Terminal Services session identifier that is associated with the token.
+ ///
+ TokenSessionId,
+
+ ///
+ /// 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.
+ ///
+ TokenGroupsAndPrivileges,
+
+ ///
+ /// Reserved.
+ ///
+ TokenSessionReference,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token includes the SANDBOX_INERT flag.
+ ///
+ TokenSandBoxInert,
+
+ ///
+ /// Reserved.
+ ///
+ TokenAuditPolicy,
+
+ ///
+ /// The buffer receives a TOKEN_ORIGIN value.
+ ///
+ TokenOrigin,
+
+ ///
+ /// The buffer receives a TOKEN_ELEVATION_TYPE value that specifies the elevation level of the token.
+ ///
+ TokenElevationType,
+
+ ///
+ /// The buffer receives a TOKEN_LINKED_TOKEN structure that contains a handle to another token that is linked to this token.
+ ///
+ TokenLinkedToken,
+
+ ///
+ /// The buffer receives a TOKEN_ELEVATION structure that specifies whether the token is elevated.
+ ///
+ TokenElevation,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token has ever been filtered.
+ ///
+ TokenHasRestrictions,
+
+ ///
+ /// The buffer receives a TOKEN_ACCESS_INFORMATION structure that specifies security information contained in the token.
+ ///
+ TokenAccessInformation,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if virtualization is allowed for the token.
+ ///
+ TokenVirtualizationAllowed,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if virtualization is enabled for the token.
+ ///
+ TokenVirtualizationEnabled,
+
+ ///
+ /// The buffer receives a TOKEN_MANDATORY_LABEL structure that specifies the token's integrity level.
+ ///
+ TokenIntegrityLevel,
+
+ ///
+ /// The buffer receives a DWORD value that is nonzero if the token has the UIAccess flag set.
+ ///
+ TokenUIAccess,
+
+ ///
+ /// The buffer receives a TOKEN_MANDATORY_POLICY structure that specifies the token's mandatory integrity policy.
+ ///
+ TokenMandatoryPolicy,
+
+ ///
+ /// The buffer receives the token's logon security identifier (SID).
+ ///
+ TokenLogonSid,
+
+ ///
+ /// The maximum value for this enumeration
+ ///
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/Desktop.Native/Windows/Shlwapi.cs b/Desktop.Native/Windows/Shlwapi.cs
new file mode 100644
index 000000000..f0b62fe31
--- /dev/null
+++ b/Desktop.Native/Windows/Shlwapi.cs
@@ -0,0 +1,51 @@
+using System.Runtime.InteropServices;
+
+namespace Remotely.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,
+}
diff --git a/Desktop.Native/Windows/User32.cs b/Desktop.Native/Windows/User32.cs
new file mode 100644
index 000000000..b7170acf6
--- /dev/null
+++ b/Desktop.Native/Windows/User32.cs
@@ -0,0 +1,1348 @@
+using Microsoft.Win32.SafeHandles;
+using System.Runtime.InteropServices;
+
+namespace Remotely.Desktop.Shared.Native.Windows;
+
+public static class User32
+{
+ #region Constants
+ public const int CURSOR_SHOWING = 0x00000001;
+ public const uint MOUSEEVENTF_ABSOLUTE = 0x8000;
+ public const int MOUSEEVENTF_LEFTDOWN = 0x02;
+ public const int MOUSEEVENTF_LEFTUP = 0x04;
+ public const int MOUSEEVENTF_RIGHTDOWN = 0x08;
+ public const int MOUSEEVENTF_RIGHTUP = 0x10;
+ public const int MOUSEEVENTF_MOVE = 0x0001;
+ public const uint KEYEVENTF_EXTENDEDKEY = 0x0001;
+ public const uint KEYEVENTF_KEYUP = 0x0002;
+
+ public const int SPIF_SENDWININICHANGE = 0x02;
+ public const int SPI_SETDESKWALLPAPER = 20;
+ public const int SPIF_UPDATEINIFILE = 1;
+ public const int SPIF_SENDCHANGE = 2;
+
+ public static readonly int SPI_GETDESKWALLPAPER = 0x73;
+ public static readonly int MAX_PATH = 260;
+ #endregion
+
+ #region Enums
+ [Flags]
+ public enum MouseEventFlags : uint
+ {
+ LEFTDOWN = 0x00000002,
+ LEFTUP = 0x00000004,
+ MIDDLEDOWN = 0x00000020,
+ MIDDLEUP = 0x00000040,
+ MOVE = 0x00000001,
+ ABSOLUTE = 0x00008000,
+ RIGHTDOWN = 0x00000008,
+ RIGHTUP = 0x00000010,
+ WHEEL = 0x00000800,
+ XDOWN = 0x00000080,
+ XUP = 0x00000100
+ }
+ [Flags]
+ public enum MOUSEEVENTF : uint
+ {
+ ABSOLUTE = 0x8000,
+ HWHEEL = 0x01000,
+ MOVE = 0x0001,
+ MOVE_NOCOALESCE = 0x2000,
+ LEFTDOWN = 0x0002,
+ LEFTUP = 0x0004,
+ RIGHTDOWN = 0x0008,
+ RIGHTUP = 0x0010,
+ MIDDLEDOWN = 0x0020,
+ MIDDLEUP = 0x0040,
+ VIRTUALDESK = 0x4000,
+ WHEEL = 0x0800,
+ XDOWN = 0x0080,
+ XUP = 0x0100
+ }
+ public enum MonitorState
+ {
+ MonitorStateOn = -1,
+ MonitorStateOff = 2,
+ MonitorStateStandBy = 1
+ }
+ [Flags]
+ public enum KEYEVENTF : uint
+ {
+ EXTENDEDKEY = 0x0001,
+ KEYUP = 0x0002,
+ SCANCODE = 0x0008,
+ UNICODE = 0x0004
+ }
+
+ public enum VirtualKey : short
+ {
+ ///
+ ///Left mouse button
+ ///
+ LBUTTON = 0x01,
+ ///
+ ///Right mouse button
+ ///
+ RBUTTON = 0x02,
+ ///
+ ///Control-break processing
+ ///
+ CANCEL = 0x03,
+ ///
+ ///Middle mouse button (three-button mouse)
+ ///
+ MBUTTON = 0x04,
+ ///
+ ///Windows 2000/XP: X1 mouse button
+ ///
+ XBUTTON1 = 0x05,
+ ///
+ ///Windows 2000/XP: X2 mouse button
+ ///
+ XBUTTON2 = 0x06,
+ ///
+ ///BACKSPACE key
+ ///
+ BACK = 0x08,
+ ///
+ ///TAB key
+ ///
+ TAB = 0x09,
+ ///
+ ///CLEAR key
+ ///
+ CLEAR = 0x0C,
+ ///
+ ///ENTER key
+ ///
+ RETURN = 0x0D,
+ ///
+ ///SHIFT key
+ ///
+ SHIFT = 0x10,
+ ///
+ ///CTRL key
+ ///
+ CONTROL = 0x11,
+ ///
+ ///ALT key
+ ///
+ MENU = 0x12,
+ ///
+ ///PAUSE key
+ ///
+ PAUSE = 0x13,
+ ///
+ ///CAPS LOCK key
+ ///
+ CAPITAL = 0x14,
+ ///
+ ///Input Method Editor (IME) Kana mode
+ ///
+ KANA = 0x15,
+ ///
+ ///IME Hangul mode
+ ///
+ HANGUL = 0x15,
+ ///
+ ///IME Junja mode
+ ///
+ JUNJA = 0x17,
+ ///
+ ///IME final mode
+ ///
+ FINAL = 0x18,
+ ///
+ ///IME Hanja mode
+ ///
+ HANJA = 0x19,
+ ///
+ ///IME Kanji mode
+ ///
+ KANJI = 0x19,
+ ///
+ ///ESC key
+ ///
+ ESCAPE = 0x1B,
+ ///
+ ///IME convert
+ ///
+ CONVERT = 0x1C,
+ ///
+ ///IME nonconvert
+ ///
+ NONCONVERT = 0x1D,
+ ///
+ ///IME accept
+ ///
+ ACCEPT = 0x1E,
+ ///
+ ///IME mode change request
+ ///
+ MODECHANGE = 0x1F,
+ ///
+ ///SPACEBAR
+ ///
+ SPACE = 0x20,
+ ///
+ ///PAGE UP key
+ ///
+ PRIOR = 0x21,
+ ///
+ ///PAGE DOWN key
+ ///
+ NEXT = 0x22,
+ ///
+ ///END key
+ ///
+ END = 0x23,
+ ///
+ ///HOME key
+ ///
+ HOME = 0x24,
+ ///
+ ///LEFT ARROW key
+ ///
+ LEFT = 0x25,
+ ///
+ ///UP ARROW key
+ ///
+ UP = 0x26,
+ ///
+ ///RIGHT ARROW key
+ ///
+ RIGHT = 0x27,
+ ///
+ ///DOWN ARROW key
+ ///
+ DOWN = 0x28,
+ ///
+ ///SELECT key
+ ///
+ SELECT = 0x29,
+ ///
+ ///PRINT key
+ ///
+ PRINT = 0x2A,
+ ///
+ ///EXECUTE key
+ ///
+ EXECUTE = 0x2B,
+ ///
+ ///PRINT SCREEN key
+ ///
+ SNAPSHOT = 0x2C,
+ ///
+ ///INS key
+ ///
+ INSERT = 0x2D,
+ ///
+ ///DEL key
+ ///
+ DELETE = 0x2E,
+ ///
+ ///HELP key
+ ///
+ HELP = 0x2F,
+ ///
+ ///0 key
+ ///
+ KEY_0 = 0x30,
+ ///
+ ///1 key
+ ///
+ KEY_1 = 0x31,
+ ///
+ ///2 key
+ ///
+ KEY_2 = 0x32,
+ ///
+ ///3 key
+ ///
+ KEY_3 = 0x33,
+ ///
+ ///4 key
+ ///
+ KEY_4 = 0x34,
+ ///
+ ///5 key
+ ///
+ KEY_5 = 0x35,
+ ///
+ ///6 key
+ ///
+ KEY_6 = 0x36,
+ ///
+ ///7 key
+ ///
+ KEY_7 = 0x37,
+ ///
+ ///8 key
+ ///
+ KEY_8 = 0x38,
+ ///
+ ///9 key
+ ///
+ KEY_9 = 0x39,
+ ///
+ ///A key
+ ///
+ KEY_A = 0x41,
+ ///
+ ///B key
+ ///
+ KEY_B = 0x42,
+ ///
+ ///C key
+ ///
+ KEY_C = 0x43,
+ ///
+ ///D key
+ ///
+ KEY_D = 0x44,
+ ///
+ ///E key
+ ///
+ KEY_E = 0x45,
+ ///
+ ///F key
+ ///
+ KEY_F = 0x46,
+ ///
+ ///G key
+ ///
+ KEY_G = 0x47,
+ ///
+ ///H key
+ ///
+ KEY_H = 0x48,
+ ///
+ ///I key
+ ///
+ KEY_I = 0x49,
+ ///
+ ///J key
+ ///
+ KEY_J = 0x4A,
+ ///
+ ///K key
+ ///
+ KEY_K = 0x4B,
+ ///
+ ///L key
+ ///
+ KEY_L = 0x4C,
+ ///
+ ///M key
+ ///
+ KEY_M = 0x4D,
+ ///
+ ///N key
+ ///
+ KEY_N = 0x4E,
+ ///
+ ///O key
+ ///
+ KEY_O = 0x4F,
+ ///
+ ///P key
+ ///
+ KEY_P = 0x50,
+ ///
+ ///Q key
+ ///
+ KEY_Q = 0x51,
+ ///
+ ///R key
+ ///
+ KEY_R = 0x52,
+ ///
+ ///S key
+ ///
+ KEY_S = 0x53,
+ ///
+ ///T key
+ ///
+ KEY_T = 0x54,
+ ///
+ ///U key
+ ///
+ KEY_U = 0x55,
+ ///
+ ///V key
+ ///
+ KEY_V = 0x56,
+ ///
+ ///W key
+ ///
+ KEY_W = 0x57,
+ ///
+ ///X key
+ ///
+ KEY_X = 0x58,
+ ///
+ ///Y key
+ ///
+ KEY_Y = 0x59,
+ ///
+ ///Z key
+ ///
+ KEY_Z = 0x5A,
+ ///
+ ///Left Windows key (Microsoft Natural keyboard)
+ ///
+ LWIN = 0x5B,
+ ///
+ ///Right Windows key (Natural keyboard)
+ ///
+ RWIN = 0x5C,
+ ///
+ ///Applications key (Natural keyboard)
+ ///
+ APPS = 0x5D,
+ ///
+ ///Computer Sleep key
+ ///
+ SLEEP = 0x5F,
+ ///
+ ///Numeric keypad 0 key
+ ///
+ NUMPAD0 = 0x60,
+ ///
+ ///Numeric keypad 1 key
+ ///
+ NUMPAD1 = 0x61,
+ ///
+ ///Numeric keypad 2 key
+ ///
+ NUMPAD2 = 0x62,
+ ///
+ ///Numeric keypad 3 key
+ ///
+ NUMPAD3 = 0x63,
+ ///
+ ///Numeric keypad 4 key
+ ///
+ NUMPAD4 = 0x64,
+ ///
+ ///Numeric keypad 5 key
+ ///
+ NUMPAD5 = 0x65,
+ ///
+ ///Numeric keypad 6 key
+ ///
+ NUMPAD6 = 0x66,
+ ///
+ ///Numeric keypad 7 key
+ ///
+ NUMPAD7 = 0x67,
+ ///
+ ///Numeric keypad 8 key
+ ///
+ NUMPAD8 = 0x68,
+ ///
+ ///Numeric keypad 9 key
+ ///
+ NUMPAD9 = 0x69,
+ ///
+ ///Multiply key
+ ///
+ MULTIPLY = 0x6A,
+ ///
+ ///Add key
+ ///
+ ADD = 0x6B,
+ ///
+ ///Separator key
+ ///
+ SEPARATOR = 0x6C,
+ ///
+ ///Subtract key
+ ///
+ SUBTRACT = 0x6D,
+ ///
+ ///Decimal key
+ ///
+ DECIMAL = 0x6E,
+ ///
+ ///Divide key
+ ///
+ DIVIDE = 0x6F,
+ ///
+ ///F1 key
+ ///
+ F1 = 0x70,
+ ///
+ ///F2 key
+ ///
+ F2 = 0x71,
+ ///
+ ///F3 key
+ ///
+ F3 = 0x72,
+ ///
+ ///F4 key
+ ///
+ F4 = 0x73,
+ ///
+ ///F5 key
+ ///
+ F5 = 0x74,
+ ///
+ ///F6 key
+ ///
+ F6 = 0x75,
+ ///
+ ///F7 key
+ ///
+ F7 = 0x76,
+ ///
+ ///F8 key
+ ///
+ F8 = 0x77,
+ ///
+ ///F9 key
+ ///
+ F9 = 0x78,
+ ///
+ ///F10 key
+ ///
+ F10 = 0x79,
+ ///
+ ///F11 key
+ ///
+ F11 = 0x7A,
+ ///
+ ///F12 key
+ ///
+ F12 = 0x7B,
+ ///
+ ///F13 key
+ ///
+ F13 = 0x7C,
+ ///
+ ///F14 key
+ ///
+ F14 = 0x7D,
+ ///
+ ///F15 key
+ ///
+ F15 = 0x7E,
+ ///
+ ///F16 key
+ ///
+ F16 = 0x7F,
+ ///
+ ///F17 key
+ ///
+ F17 = 0x80,
+ ///
+ ///F18 key
+ ///
+ F18 = 0x81,
+ ///
+ ///F19 key
+ ///
+ F19 = 0x82,
+ ///
+ ///F20 key
+ ///
+ F20 = 0x83,
+ ///
+ ///F21 key
+ ///
+ F21 = 0x84,
+ ///
+ ///F22 key, (PPC only) Key used to lock device.
+ ///
+ F22 = 0x85,
+ ///
+ ///F23 key
+ ///
+ F23 = 0x86,
+ ///
+ ///F24 key
+ ///
+ F24 = 0x87,
+ ///
+ ///NUM LOCK key
+ ///
+ NUMLOCK = 0x90,
+ ///
+ ///SCROLL LOCK key
+ ///
+ SCROLL = 0x91,
+ ///
+ ///Left SHIFT key
+ ///
+ LSHIFT = 0xA0,
+ ///
+ ///Right SHIFT key
+ ///
+ RSHIFT = 0xA1,
+ ///
+ ///Left CONTROL key
+ ///
+ LCONTROL = 0xA2,
+ ///
+ ///Right CONTROL key
+ ///
+ RCONTROL = 0xA3,
+ ///
+ ///Left MENU key
+ ///
+ LMENU = 0xA4,
+ ///
+ ///Right MENU key
+ ///
+ RMENU = 0xA5,
+ ///
+ ///Windows 2000/XP: Browser Back key
+ ///
+ BROWSER_BACK = 0xA6,
+ ///
+ ///Windows 2000/XP: Browser Forward key
+ ///
+ BROWSER_FORWARD = 0xA7,
+ ///
+ ///Windows 2000/XP: Browser Refresh key
+ ///
+ BROWSER_REFRESH = 0xA8,
+ ///
+ ///Windows 2000/XP: Browser Stop key
+ ///
+ BROWSER_STOP = 0xA9,
+ ///
+ ///Windows 2000/XP: Browser Search key
+ ///
+ BROWSER_SEARCH = 0xAA,
+ ///
+ ///Windows 2000/XP: Browser Favorites key
+ ///
+ BROWSER_FAVORITES = 0xAB,
+ ///
+ ///Windows 2000/XP: Browser Start and Home key
+ ///
+ BROWSER_HOME = 0xAC,
+ ///
+ ///Windows 2000/XP: Volume Mute key
+ ///
+ VOLUME_MUTE = 0xAD,
+ ///
+ ///Windows 2000/XP: Volume Down key
+ ///
+ VOLUME_DOWN = 0xAE,
+ ///
+ ///Windows 2000/XP: Volume Up key
+ ///
+ VOLUME_UP = 0xAF,
+ ///
+ ///Windows 2000/XP: Next Track key
+ ///
+ MEDIA_NEXT_TRACK = 0xB0,
+ ///
+ ///Windows 2000/XP: Previous Track key
+ ///
+ MEDIA_PREV_TRACK = 0xB1,
+ ///
+ ///Windows 2000/XP: Stop Media key
+ ///
+ MEDIA_STOP = 0xB2,
+ ///
+ ///Windows 2000/XP: Play/Pause Media key
+ ///
+ MEDIA_PLAY_PAUSE = 0xB3,
+ ///
+ ///Windows 2000/XP: Start Mail key
+ ///
+ LAUNCH_MAIL = 0xB4,
+ ///
+ ///Windows 2000/XP: Select Media key
+ ///
+ LAUNCH_MEDIA_SELECT = 0xB5,
+ ///
+ ///Windows 2000/XP: Start Application 1 key
+ ///
+ LAUNCH_APP1 = 0xB6,
+ ///
+ ///Windows 2000/XP: Start Application 2 key
+ ///
+ LAUNCH_APP2 = 0xB7,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_1 = 0xBA,
+ ///
+ ///Windows 2000/XP: For any country/region, the '+' key
+ ///
+ OEM_PLUS = 0xBB,
+ ///
+ ///Windows 2000/XP: For any country/region, the ',' key
+ ///
+ OEM_COMMA = 0xBC,
+ ///
+ ///Windows 2000/XP: For any country/region, the '-' key
+ ///
+ OEM_MINUS = 0xBD,
+ ///
+ ///Windows 2000/XP: For any country/region, the '.' key
+ ///
+ OEM_PERIOD = 0xBE,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_2 = 0xBF,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_3 = 0xC0,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_4 = 0xDB,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_5 = 0xDC,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_6 = 0xDD,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_7 = 0xDE,
+ ///
+ ///Used for miscellaneous characters; it can vary by keyboard.
+ ///
+ OEM_8 = 0xDF,
+ ///
+ ///Windows 2000/XP: Either the angle bracket key or the backslash key on the RT 102-key keyboard
+ ///
+ OEM_102 = 0xE2,
+ ///
+ ///Windows 95/98/Me, Windows NT 4.0, Windows 2000/XP: IME PROCESS key
+ ///
+ PROCESSKEY = 0xE5,
+ ///
+ ///Windows 2000/XP: Used to pass Unicode characters as if they were keystrokes.
+ ///The VK_PACKET key is the low word of a 32-bit Virtual Key value used for non-keyboard input methods. For more information,
+ ///see Remark in KEYBDINPUT, SendInput, WM_KEYDOWN, and WM_KEYUP
+ ///
+ PACKET = 0xE7,
+ ///
+ ///Attn key
+ ///
+ ATTN = 0xF6,
+ ///
+ ///CrSel key
+ ///
+ CRSEL = 0xF7,
+ ///
+ ///ExSel key
+ ///
+ EXSEL = 0xF8,
+ ///
+ ///Erase EOF key
+ ///
+ EREOF = 0xF9,
+ ///
+ ///Play key
+ ///
+ PLAY = 0xFA,
+ ///
+ ///Zoom key
+ ///
+ ZOOM = 0xFB,
+ ///
+ ///Reserved
+ ///
+ NONAME = 0xFC,
+ ///
+ ///PA1 key
+ ///
+ PA1 = 0xFD,
+ ///
+ ///Clear key
+ ///
+ OEM_CLEAR = 0xFE
+ }
+ public enum ScanCodeShort : short
+ {
+ LBUTTON = 0,
+ RBUTTON = 0,
+ CANCEL = 70,
+ MBUTTON = 0,
+ XBUTTON1 = 0,
+ XBUTTON2 = 0,
+ BACK = 14,
+ TAB = 15,
+ CLEAR = 76,
+ RETURN = 28,
+ SHIFT = 42,
+ CONTROL = 29,
+ MENU = 56,
+ PAUSE = 0,
+ CAPITAL = 58,
+ KANA = 0,
+ HANGUL = 0,
+ JUNJA = 0,
+ FINAL = 0,
+ HANJA = 0,
+ KANJI = 0,
+ ESCAPE = 1,
+ CONVERT = 0,
+ NONCONVERT = 0,
+ ACCEPT = 0,
+ MODECHANGE = 0,
+ SPACE = 57,
+ PRIOR = 73,
+ NEXT = 81,
+ END = 79,
+ HOME = 71,
+ LEFT = 75,
+ UP = 72,
+ RIGHT = 77,
+ DOWN = 80,
+ SELECT = 0,
+ PRINT = 0,
+ EXECUTE = 0,
+ SNAPSHOT = 84,
+ INSERT = 82,
+ DELETE = 83,
+ HELP = 99,
+ KEY_0 = 11,
+ KEY_1 = 2,
+ KEY_2 = 3,
+ KEY_3 = 4,
+ KEY_4 = 5,
+ KEY_5 = 6,
+ KEY_6 = 7,
+ KEY_7 = 8,
+ KEY_8 = 9,
+ KEY_9 = 10,
+ KEY_A = 30,
+ KEY_B = 48,
+ KEY_C = 46,
+ KEY_D = 32,
+ KEY_E = 18,
+ KEY_F = 33,
+ KEY_G = 34,
+ KEY_H = 35,
+ KEY_I = 23,
+ KEY_J = 36,
+ KEY_K = 37,
+ KEY_L = 38,
+ KEY_M = 50,
+ KEY_N = 49,
+ KEY_O = 24,
+ KEY_P = 25,
+ KEY_Q = 16,
+ KEY_R = 19,
+ KEY_S = 31,
+ KEY_T = 20,
+ KEY_U = 22,
+ KEY_V = 47,
+ KEY_W = 17,
+ KEY_X = 45,
+ KEY_Y = 21,
+ KEY_Z = 44,
+ LWIN = 91,
+ RWIN = 92,
+ APPS = 93,
+ SLEEP = 95,
+ NUMPAD0 = 82,
+ NUMPAD1 = 79,
+ NUMPAD2 = 80,
+ NUMPAD3 = 81,
+ NUMPAD4 = 75,
+ NUMPAD5 = 76,
+ NUMPAD6 = 77,
+ NUMPAD7 = 71,
+ NUMPAD8 = 72,
+ NUMPAD9 = 73,
+ MULTIPLY = 55,
+ ADD = 78,
+ SEPARATOR = 0,
+ SUBTRACT = 74,
+ DECIMAL = 83,
+ DIVIDE = 53,
+ F1 = 59,
+ F2 = 60,
+ F3 = 61,
+ F4 = 62,
+ F5 = 63,
+ F6 = 64,
+ F7 = 65,
+ F8 = 66,
+ F9 = 67,
+ F10 = 68,
+ F11 = 87,
+ F12 = 88,
+ F13 = 100,
+ F14 = 101,
+ F15 = 102,
+ F16 = 103,
+ F17 = 104,
+ F18 = 105,
+ F19 = 106,
+ F20 = 107,
+ F21 = 108,
+ F22 = 109,
+ F23 = 110,
+ F24 = 118,
+ NUMLOCK = 69,
+ SCROLL = 70,
+ LSHIFT = 42,
+ RSHIFT = 54,
+ LCONTROL = 29,
+ RCONTROL = 29,
+ LMENU = 56,
+ RMENU = 56,
+ BROWSER_BACK = 106,
+ BROWSER_FORWARD = 105,
+ BROWSER_REFRESH = 103,
+ BROWSER_STOP = 104,
+ BROWSER_SEARCH = 101,
+ BROWSER_FAVORITES = 102,
+ BROWSER_HOME = 50,
+ VOLUME_MUTE = 32,
+ VOLUME_DOWN = 46,
+ VOLUME_UP = 48,
+ MEDIA_NEXT_TRACK = 25,
+ MEDIA_PREV_TRACK = 16,
+ MEDIA_STOP = 36,
+ MEDIA_PLAY_PAUSE = 34,
+ LAUNCH_MAIL = 108,
+ LAUNCH_MEDIA_SELECT = 109,
+ LAUNCH_APP1 = 107,
+ LAUNCH_APP2 = 33,
+ OEM_1 = 39,
+ OEM_PLUS = 13,
+ OEM_COMMA = 51,
+ OEM_MINUS = 12,
+ OEM_PERIOD = 52,
+ OEM_2 = 53,
+ OEM_3 = 41,
+ OEM_4 = 26,
+ OEM_5 = 43,
+ OEM_6 = 27,
+ OEM_7 = 40,
+ OEM_8 = 0,
+ OEM_102 = 86,
+ PROCESSKEY = 0,
+ PACKET = 0,
+ ATTN = 0,
+ CRSEL = 0,
+ EXSEL = 0,
+ EREOF = 93,
+ PLAY = 0,
+ ZOOM = 98,
+ NONAME = 0,
+ PA1 = 0,
+ OEM_CLEAR = 0,
+ }
+ [Flags]
+ public enum ACCESS_MASK : uint
+ {
+ DELETE = 0x00010000,
+ READ_CONTROL = 0x00020000,
+ WRITE_DAC = 0x00040000,
+ WRITE_OWNER = 0x00080000,
+ SYNCHRONIZE = 0x00100000,
+
+ STANDARD_RIGHTS_REQUIRED = 0x000F0000,
+
+ STANDARD_RIGHTS_READ = 0x00020000,
+ STANDARD_RIGHTS_WRITE = 0x00020000,
+ STANDARD_RIGHTS_EXECUTE = 0x00020000,
+
+ STANDARD_RIGHTS_ALL = 0x001F0000,
+
+ SPECIFIC_RIGHTS_ALL = 0x0000FFFF,
+
+ ACCESS_SYSTEM_SECURITY = 0x01000000,
+
+ MAXIMUM_ALLOWED = 0x02000000,
+
+ GENERIC_READ = 0x80000000,
+ GENERIC_WRITE = 0x40000000,
+ GENERIC_EXECUTE = 0x20000000,
+ GENERIC_ALL = 0x10000000,
+
+ DESKTOP_READOBJECTS = 0x00000001,
+ DESKTOP_CREATEWINDOW = 0x00000002,
+ DESKTOP_CREATEMENU = 0x00000004,
+ DESKTOP_HOOKCONTROL = 0x00000008,
+ DESKTOP_JOURNALRECORD = 0x00000010,
+ DESKTOP_JOURNALPLAYBACK = 0x00000020,
+ DESKTOP_ENUMERATE = 0x00000040,
+ DESKTOP_WRITEOBJECTS = 0x00000080,
+ DESKTOP_SWITCHDESKTOP = 0x00000100,
+
+ WINSTA_ENUMDESKTOPS = 0x00000001,
+ WINSTA_READATTRIBUTES = 0x00000002,
+ WINSTA_ACCESSCLIPBOARD = 0x00000004,
+ WINSTA_CREATEDESKTOP = 0x00000008,
+ WINSTA_WRITEATTRIBUTES = 0x00000010,
+ WINSTA_ACCESSGLOBALATOMS = 0x00000020,
+ WINSTA_EXITWINDOWS = 0x00000040,
+ WINSTA_ENUMERATE = 0x00000100,
+ WINSTA_READSCREEN = 0x00000200,
+
+ WINSTA_ALL_ACCESS = 0x0000037F
+ }
+ public enum InputType : uint
+ {
+ MOUSE = 0,
+ KEYBOARD = 1,
+ HARDWARE = 2
+ }
+ public enum MessageBoxType : long
+ {
+ MB_ABORTRETRYIGNORE = 0x00000002L,
+ MB_CANCELTRYCONTINUE = 0x00000006L,
+ MB_HELP = 0x00004000L,
+ MB_OK = 0x00000000L,
+ MB_OKCANCEL = 0x00000001L,
+ MB_RETRYCANCEL = 0x00000005L,
+ MB_YESNO = 0x00000004L,
+ MB_YESNOCANCEL = 0x00000003L,
+ MB_ICONEXCLAMATION = 0x00000030L,
+ MB_ICONWARNING = 0x00000030L,
+ MB_ICONINFORMATION = 0x00000040L,
+ MB_ICONASTERISK = 0x00000040L,
+ MB_ICONQUESTION = 0x00000020L,
+ MB_ICONSTOP = 0x00000010L,
+ MB_ICONERROR = 0x00000010L,
+ MB_ICONHAND = 0x00000010L,
+ MB_DEFBUTTON1 = 0x00000000L,
+ MB_DEFBUTTON2 = 0x00000100L,
+ MB_DEFBUTTON3 = 0x00000200L,
+ MB_DEFBUTTON4 = 0x00000300L,
+ MB_APPLMODAL = 0x00000000L,
+ MB_SYSTEMMODAL = 0x00001000L,
+ MB_TASKMODAL = 0x00002000L,
+ MB_DEFAULT_DESKTOP_ONLY = 0x00020000L,
+ MB_RIGHT = 0x00080000L,
+ MB_RTLREADING = 0x00100000L,
+ MB_SETFOREGROUND = 0x00010000L,
+ MB_TOPMOST = 0x00040000L,
+ MB_SERVICE_NOTIFICATION = 0x00200000L
+ }
+
+ public enum MessageBoxResult : int
+ {
+ IDABORT = 3,
+ IDCANCEL = 2,
+ IDCONTINUE = 11,
+ IDIGNORE = 5,
+ IDNO = 7,
+ IDOK = 1,
+ IDRETRY = 4,
+ IDTRYAGAIN = 10,
+ IDYES = 6,
+ }
+ public enum SW
+ {
+ SW_HIDE = 0,
+ SW_SHOWNORMAL = 1,
+ SW_NORMAL = 1,
+ SW_SHOWMINIMIZED = 2,
+ SW_SHOWMAXIMIZED = 3,
+ SW_MAXIMIZE = 3,
+ SW_SHOWNOACTIVATE = 4,
+ SW_SHOW = 5,
+ SW_MINIMIZE = 6,
+ SW_SHOWMINNOACTIVE = 7,
+ SW_SHOWNA = 8,
+ SW_RESTORE = 9,
+ SW_SHOWDEFAULT = 10,
+ SW_MAX = 10
+ }
+ public enum VkMapType : uint
+ {
+ MAPVK_VK_TO_VSC = 0,
+ MAPVK_VSC_TO_VK = 1,
+ MAPVK_VK_TO_CHAR = 2,
+ MAPVK_VSC_TO_VK_EX = 3,
+ MAPVK_VK_TO_VSC_EX = 4
+ }
+
+ #endregion
+
+ #region Structs
+ [StructLayout(LayoutKind.Sequential)]
+ public struct ICONINFO
+ {
+ public bool fIcon;
+ public int xHotspot;
+ public int yHotspot;
+ public nint hbmMask;
+ public nint hbmColor;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct POINT
+ {
+ public int x;
+ public int y;
+ }
+
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct CursorInfo
+ {
+ public int cbSize;
+ public int flags;
+ public nint hCursor;
+ public POINT ptScreenPos;
+ }
+ [StructLayout(LayoutKind.Sequential)]
+ public struct INPUT
+ {
+ public InputType type;
+ public InputUnion U;
+ public static int Size
+ {
+ get { return Marshal.SizeOf(typeof(INPUT)); }
+ }
+ }
+
+ [StructLayout(LayoutKind.Explicit)]
+ public struct InputUnion
+ {
+ [FieldOffset(0)]
+ public MOUSEINPUT mi;
+ [FieldOffset(0)]
+ public KEYBDINPUT ki;
+ [FieldOffset(0)]
+ public HARDWAREINPUT hi;
+ }
+
+
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct MOUSEINPUT
+ {
+ public int dx;
+ public int dy;
+ public int mouseData;
+ public MOUSEEVENTF dwFlags;
+ public uint time;
+ public nuint dwExtraInfo;
+ }
+ [StructLayout(LayoutKind.Sequential)]
+ public struct KEYBDINPUT
+ {
+ public VirtualKey wVk;
+ public ushort wScan;
+ public KEYEVENTF dwFlags;
+ public int time;
+ public nuint dwExtraInfo;
+ }
+
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct HARDWAREINPUT
+ {
+ public int uMsg;
+ public short wParamL;
+ public short wParamH;
+ }
+ #endregion
+
+ #region DLL Imports
+ [DllImport("user32.dll")]
+ public static extern bool GetCursorInfo(out CursorInfo pci);
+ [DllImport("user32.dll", SetLastError = false)]
+ public static extern nint GetDesktopWindow();
+
+ [DllImport("user32.dll")]
+ public static extern nint GetCursor();
+
+ [DllImport("user32.dll")]
+ public static extern nint CopyIcon(nint hIcon);
+
+ [DllImport("user32.dll")]
+ public static extern bool DrawIcon(nint hdc, int x, int y, nint hIcon);
+
+ [DllImport("user32.dll")]
+ public static extern bool GetIconInfo(nint hIcon, out ICONINFO piconinfo);
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
+ public static extern void Mouse_event(uint dwFlags, uint dx, uint dy, uint cButtons, nuint dwExtraInfo);
+
+ [DllImport("user32.dll")]
+ public static extern void Keybd_event(byte bVk, byte bScan, uint dwFlags, nuint dwExtraInfo);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetCursorPos(out System.Drawing.Point lpPoint);
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool SetCursorPos(int x, int y);
+
+ [DllImport("user32.dll")]
+ public static extern nint SetCursor(nint hcursor);
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ static extern nint LoadImage(nint hinst, string lpszName, uint uType,
+ int cxDesired, int cyDesired, uint fuLoad);
+
+ [DllImport("user32.dll")]
+ public static extern nint CreateCursor(nint hInst, int xHotSpot, int yHotSpot,
+ int nWidth, int nHeight, byte[] pvANDPlane, byte[] pvXORPlane);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool PrintWindow(nint hwnd, nint hDC, uint nFlags);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool SwitchDesktop(nint hDesktop);
+
+ public delegate bool EnumDesktopsDelegate(string desktop, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern bool EnumDesktopsA(nint hwinsta, EnumDesktopsDelegate lpEnumFunc, nint lParam);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern nint OpenInputDesktop(uint dwFlags, bool fInherit, ACCESS_MASK dwDesiredAccess);
+
+ public delegate bool EnumWindowStationsDelegate(string windowsStation, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern bool EnumWindowStations(EnumWindowStationsDelegate lpEnumFunc, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetShellWindow();
+
+ public sealed class SafeWindowStationHandle : SafeHandleZeroOrMinusOneIsInvalid
+ {
+ public SafeWindowStationHandle()
+ : base(true)
+ {
+ }
+
+ protected override bool ReleaseHandle()
+ {
+ return CloseWindowStation(handle);
+
+ }
+ }
+
+ [return: MarshalAs(UnmanagedType.Bool)]
+ [DllImport("user32", CharSet = CharSet.Unicode, SetLastError = true)]
+ public static extern bool CloseWindowStation(nint hWinsta);
+
+ [DllImport("user32", CharSet = CharSet.Unicode, SetLastError = true)]
+ public static extern SafeWindowStationHandle OpenWindowStation([MarshalAs(UnmanagedType.LPTStr)] string lpszWinSta, [MarshalAs(UnmanagedType.Bool)] bool fInherit, ACCESS_MASK dwDesiredAccess);
+
+ [DllImport("user32", CharSet = CharSet.Unicode, SetLastError = true)]
+ public static extern nint OpenWindowStationW([MarshalAs(UnmanagedType.LPTStr)] string lpszWinSta, [MarshalAs(UnmanagedType.Bool)] bool fInherit, ACCESS_MASK dwDesiredAccess);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool SetProcessWindowStation(nint hWinSta);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetWindowDC(nint hWnd);
+
+ public delegate bool EnumWindowsProc(nint hwnd, nint lParam);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool EnumChildWindows(nint hwndParent, EnumWindowsProc lpEnumFunc, nint lParam);
+
+ [DllImport("User32.dll")]
+ public static extern int ReleaseDC(nint hWnd, nint hDC);
+
+ [DllImport("User32.dll")]
+ public static extern nint GetProcessWindowStation();
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern nint GetThreadDesktop(uint threadId);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool SetThreadDesktop(nint hDesktop);
+
+ [DllImport("user32.dll")]
+ public static extern nint OpenDesktop(string lpszDesktop, uint dwFlags, bool fInherit, ACCESS_MASK dwDesiredAccess);
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool CloseDesktop(nint hDesktop);
+
+ public delegate bool EnumDesktopWindowsDelegate(nint hWnd, int lParam);
+
+ [DllImport("user32.dll")]
+ public static extern bool EnumDesktopWindows(nint hDesktop, EnumDesktopWindowsDelegate lpfn, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetDC(nint hWnd);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern nint SetActiveWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool SetForegroundWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ public static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray), In] INPUT[] pInputs, int cbSize);
+
+ [DllImport("user32.dll", SetLastError = false)]
+ public static extern nuint GetMessageExtraInfo();
+ [DllImport("sas.dll")]
+ public static extern void SendSAS(bool AsUser);
+ [DllImport("user32.dll")]
+ public static extern bool OpenClipboard(nint hWnd);
+ [DllImport("user32.dll")]
+ public static extern bool EmptyClipboard();
+ [DllImport("user32.dll")]
+ public static extern bool CloseClipboard();
+ [DllImport("user32.dll")]
+ public static extern nint SetClipboardData(int Format, nint hMem);
+
+ [DllImport("user32.dll", EntryPoint = "ShowWindow", SetLastError = true)]
+ public static extern bool ShowWindow(nint hWnd, int nCmdShow);
+ /*
+* SystemParametersInfo(
+* SPI_SETDESKWALLPAPER, 0, "filename.bmp",
+* SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
+*/
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
+ public static extern int SystemParametersInfo(
+ int uAction, int uParam, string lpvParam, int fuWinIni);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool LockWorkStation();
+
+ [DllImport("user32.dll")]
+ public static extern short VkKeyScan(char ch);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern short VkKeyScanEx(char ch, nint dwhkl);
+
+ [DllImport("user32.dll")]
+ public static extern int SendMessage(int hWnd, int hMsg, int wParam, int lParam);
+
+ [DllImport("user32.dll", EntryPoint = "BlockInput")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool BlockInput([MarshalAs(UnmanagedType.Bool)] bool fBlockIt);
+
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ public static extern int MessageBox(nint hWnd, string text, string caption, long type);
+
+ [DllImport("USER32.dll")]
+ public static extern short GetKeyState(VirtualKey nVirtKey);
+
+ [DllImport("user32.dll")]
+ public static extern uint MapVirtualKeyEx(uint uCode, VkMapType uMapType, nint dwhkl);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetKeyboardLayout(uint threadId = 0);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ public static extern bool GetUserObjectInformationW(nint hObj, int nIndex,
+ [Out] byte[] pvInfo, uint nLength, out uint lpnLengthNeeded);
+ #endregion
+}
diff --git a/Desktop.Native/Windows/WTSAPI32.cs b/Desktop.Native/Windows/WTSAPI32.cs
new file mode 100644
index 000000000..f170f32e5
--- /dev/null
+++ b/Desktop.Native/Windows/WTSAPI32.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace Remotely.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;
+ }
+}
diff --git a/Desktop.Native/Windows/Win32Interop.cs b/Desktop.Native/Windows/Win32Interop.cs
new file mode 100644
index 000000000..c565a3f60
--- /dev/null
+++ b/Desktop.Native/Windows/Win32Interop.cs
@@ -0,0 +1,281 @@
+using Remotely.Shared.Models;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
+using System.Text;
+using static Remotely.Desktop.Shared.Native.Windows.ADVAPI32;
+using static Remotely.Desktop.Shared.Native.Windows.User32;
+
+namespace Remotely.Desktop.Shared.Native.Windows;
+
+// TODO: Use https://github.com/microsoft/CsWin32 for all p/invokes.
+public class Win32Interop
+{
+ public static List GetActiveSessions()
+ {
+ var sessions = new List();
+ 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;
+ }
+}
diff --git a/Desktop.Shared/Abstractions/IAppStartup.cs b/Desktop.Shared/Abstractions/IAppStartup.cs
new file mode 100644
index 000000000..a8051d794
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IAppStartup.cs
@@ -0,0 +1,6 @@
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IAppStartup
+{
+ Task Run();
+}
diff --git a/Desktop.Shared/Abstractions/IAudioCapturer.cs b/Desktop.Shared/Abstractions/IAudioCapturer.cs
new file mode 100644
index 000000000..1080bd083
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IAudioCapturer.cs
@@ -0,0 +1,7 @@
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IAudioCapturer
+{
+ event EventHandler AudioSampleReady;
+ void ToggleAudio(bool toggleOn);
+}
diff --git a/Desktop.Shared/Abstractions/IChatUiService.cs b/Desktop.Shared/Abstractions/IChatUiService.cs
new file mode 100644
index 000000000..1971e1dbc
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IChatUiService.cs
@@ -0,0 +1,11 @@
+using Remotely.Shared.Models;
+
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IChatUiService
+{
+ event EventHandler ChatWindowClosed;
+
+ void ShowChatWindow(string organizationName, StreamWriter writer);
+ Task ReceiveChat(ChatMessage chatMessage);
+}
diff --git a/Desktop.Shared/Abstractions/IClipboardService.cs b/Desktop.Shared/Abstractions/IClipboardService.cs
new file mode 100644
index 000000000..2facc8e27
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IClipboardService.cs
@@ -0,0 +1,10 @@
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IClipboardService
+{
+ event EventHandler ClipboardTextChanged;
+
+ void BeginWatching();
+
+ Task SetText(string clipboardText);
+}
diff --git a/Desktop.Shared/Abstractions/ICursorIconWatcher.cs b/Desktop.Shared/Abstractions/ICursorIconWatcher.cs
new file mode 100644
index 000000000..577f7308e
--- /dev/null
+++ b/Desktop.Shared/Abstractions/ICursorIconWatcher.cs
@@ -0,0 +1,11 @@
+using Remotely.Shared.Models;
+
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface ICursorIconWatcher
+{
+ [Obsolete("This should be replaced with a message published by IMessenger.")]
+ event EventHandler OnChange;
+
+ CursorInfo GetCurrentCursor();
+}
diff --git a/Desktop.Shared/Abstractions/IFileTransferService.cs b/Desktop.Shared/Abstractions/IFileTransferService.cs
new file mode 100644
index 000000000..5ae12af38
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IFileTransferService.cs
@@ -0,0 +1,13 @@
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.ViewModels;
+
+namespace Remotely.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 progressUpdateCallback, CancellationToken cancelToken);
+}
diff --git a/Desktop.Shared/Abstractions/IKeyboardMouseInput.cs b/Desktop.Shared/Abstractions/IKeyboardMouseInput.cs
new file mode 100644
index 000000000..693414788
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IKeyboardMouseInput.cs
@@ -0,0 +1,17 @@
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Services;
+
+namespace Remotely.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);
+}
diff --git a/Desktop.Shared/Abstractions/IRemoteControlAccessService.cs b/Desktop.Shared/Abstractions/IRemoteControlAccessService.cs
new file mode 100644
index 000000000..1374e6477
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IRemoteControlAccessService.cs
@@ -0,0 +1,10 @@
+using Remotely.Shared.Enums;
+
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IRemoteControlAccessService
+{
+ bool IsPromptOpen { get; }
+
+ Task PromptForAccess(string requesterName, string organizationName);
+}
diff --git a/Desktop.Shared/Abstractions/IScreenCapturer.cs b/Desktop.Shared/Abstractions/IScreenCapturer.cs
new file mode 100644
index 000000000..960c41c91
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IScreenCapturer.cs
@@ -0,0 +1,29 @@
+using Remotely.Shared.Primitives;
+using SkiaSharp;
+using System.Drawing;
+
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IScreenCapturer : IDisposable
+{
+ event EventHandler ScreenChanged;
+
+ bool CaptureFullscreen { get; set; }
+ Rectangle CurrentScreenBounds { get; }
+ bool IsGpuAccelerated { get; }
+ string SelectedScreen { get; }
+ IEnumerable GetDisplayNames();
+ SKRect GetFrameDiffArea();
+
+ Result GetImageDiff();
+
+ Result GetNextFrame();
+
+ int GetScreenCount();
+
+ Rectangle GetVirtualScreenBounds();
+
+ void Init();
+
+ void SetSelectedScreen(string displayName);
+}
diff --git a/Desktop.Shared/Abstractions/ISessionIndicator.cs b/Desktop.Shared/Abstractions/ISessionIndicator.cs
new file mode 100644
index 000000000..b69f8bd7e
--- /dev/null
+++ b/Desktop.Shared/Abstractions/ISessionIndicator.cs
@@ -0,0 +1,6 @@
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface ISessionIndicator
+{
+ void Show();
+}
diff --git a/Desktop.Shared/Abstractions/IShutdownService.cs b/Desktop.Shared/Abstractions/IShutdownService.cs
new file mode 100644
index 000000000..2510a7060
--- /dev/null
+++ b/Desktop.Shared/Abstractions/IShutdownService.cs
@@ -0,0 +1,6 @@
+namespace Remotely.Desktop.Shared.Abstractions;
+
+public interface IShutdownService
+{
+ Task Shutdown();
+}
diff --git a/Agent.Installer.Win/Assets/favicon.ico b/Desktop.Shared/Assets/DefaultIcon.ico
similarity index 100%
rename from Agent.Installer.Win/Assets/favicon.ico
rename to Desktop.Shared/Assets/DefaultIcon.ico
diff --git a/Agent.Installer.Win/Assets/Remotely_Icon.png b/Desktop.Shared/Assets/DefaultIcon.png
similarity index 100%
rename from Agent.Installer.Win/Assets/Remotely_Icon.png
rename to Desktop.Shared/Assets/DefaultIcon.png
diff --git a/Desktop.Shared/Desktop.Shared.csproj b/Desktop.Shared/Desktop.Shared.csproj
index 63d3dcaad..d3d629d21 100644
--- a/Desktop.Shared/Desktop.Shared.csproj
+++ b/Desktop.Shared/Desktop.Shared.csproj
@@ -4,24 +4,40 @@
net8.0
enable
enable
+ True
+ Remotely.Desktop.Shared
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
diff --git a/Desktop.Shared/Enums/AppMode.cs b/Desktop.Shared/Enums/AppMode.cs
new file mode 100644
index 000000000..6133401c8
--- /dev/null
+++ b/Desktop.Shared/Enums/AppMode.cs
@@ -0,0 +1,8 @@
+namespace Remotely.Desktop.Shared.Enums;
+
+public enum AppMode
+{
+ Unattended,
+ Attended,
+ Chat
+}
diff --git a/Desktop.Shared/Enums/ButtonAction.cs b/Desktop.Shared/Enums/ButtonAction.cs
new file mode 100644
index 000000000..8a3df55da
--- /dev/null
+++ b/Desktop.Shared/Enums/ButtonAction.cs
@@ -0,0 +1,7 @@
+namespace Remotely.Desktop.Shared.Enums;
+
+public enum ButtonAction
+{
+ Down,
+ Up
+}
diff --git a/Desktop.Shared/Extensions/SKBitmapExtensions.cs b/Desktop.Shared/Extensions/SKBitmapExtensions.cs
new file mode 100644
index 000000000..06c9aa4ea
--- /dev/null
+++ b/Desktop.Shared/Extensions/SKBitmapExtensions.cs
@@ -0,0 +1,11 @@
+using SkiaSharp;
+
+namespace Remotely.Desktop.Shared.Extensions;
+
+public static class SKBitmapExtensions
+{
+ public static SKRect ToRectangle(this SKBitmap bitmap)
+ {
+ return new SKRect(0, 0, bitmap.Width, bitmap.Height);
+ }
+}
diff --git a/Desktop.Shared/Messages/AppStateHostChangedMessage.cs b/Desktop.Shared/Messages/AppStateHostChangedMessage.cs
new file mode 100644
index 000000000..485771b24
--- /dev/null
+++ b/Desktop.Shared/Messages/AppStateHostChangedMessage.cs
@@ -0,0 +1,11 @@
+namespace Remotely.Desktop.Shared.Messages;
+
+public class AppStateHostChangedMessage
+{
+ public AppStateHostChangedMessage(string newHost)
+ {
+ NewHost = newHost;
+ }
+
+ public string NewHost { get; }
+}
diff --git a/Desktop.Shared/Messages/DisplaySettingsChangedMessage.cs b/Desktop.Shared/Messages/DisplaySettingsChangedMessage.cs
new file mode 100644
index 000000000..4be32a233
--- /dev/null
+++ b/Desktop.Shared/Messages/DisplaySettingsChangedMessage.cs
@@ -0,0 +1,2 @@
+namespace Remotely.Desktop.Shared.Messages;
+public record DisplaySettingsChangedMessage();
diff --git a/Desktop.Shared/Messages/WindowsSessionEndingMessage.cs b/Desktop.Shared/Messages/WindowsSessionEndingMessage.cs
new file mode 100644
index 000000000..893419a69
--- /dev/null
+++ b/Desktop.Shared/Messages/WindowsSessionEndingMessage.cs
@@ -0,0 +1,13 @@
+using Remotely.Shared.Enums;
+
+namespace Remotely.Desktop.Shared.Messages;
+
+public class WindowsSessionEndingMessage
+{
+ public WindowsSessionEndingMessage(SessionEndReasonsEx reason)
+ {
+ Reason = reason;
+ }
+
+ public SessionEndReasonsEx Reason { get; }
+}
diff --git a/Desktop.Shared/Messages/WindowsSessionSwitchedMessage.cs b/Desktop.Shared/Messages/WindowsSessionSwitchedMessage.cs
new file mode 100644
index 000000000..7fd6a6c4a
--- /dev/null
+++ b/Desktop.Shared/Messages/WindowsSessionSwitchedMessage.cs
@@ -0,0 +1,15 @@
+using Remotely.Shared.Enums;
+
+namespace Remotely.Desktop.Shared.Messages;
+
+public class WindowsSessionSwitchedMessage
+{
+ public WindowsSessionSwitchedMessage(SessionSwitchReasonEx reason, int sessionId)
+ {
+ Reason = reason;
+ SessionId = sessionId;
+ }
+
+ public SessionSwitchReasonEx Reason { get; }
+ public int SessionId { get; }
+}
diff --git a/Desktop.Shared/Properties/AssemblyInfo.cs b/Desktop.Shared/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..ddd26d78c
--- /dev/null
+++ b/Desktop.Shared/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Remotely_Desktop")]
\ No newline at end of file
diff --git a/Desktop.Shared/Reactive/AsyncRelayCommand.cs b/Desktop.Shared/Reactive/AsyncRelayCommand.cs
new file mode 100644
index 000000000..ac57274c2
--- /dev/null
+++ b/Desktop.Shared/Reactive/AsyncRelayCommand.cs
@@ -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 Remotely.Desktop.Shared.Reactive;
+
+public class AsyncRelayCommand : ICommand
+{
+ private readonly Func _canExecute;
+ private readonly Func _execute;
+ public AsyncRelayCommand(Func execute)
+ {
+ _execute = execute;
+ _canExecute = () => true;
+ }
+
+ public AsyncRelayCommand(Func execute, Func 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 : ICommand
+{
+ private readonly Func _canExecute;
+ private readonly Func _execute;
+
+ public AsyncRelayCommand(Func execute)
+ {
+ _execute = execute;
+ _canExecute = (parameter) => true;
+ }
+
+ public AsyncRelayCommand(Func execute, Func 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);
+ }
+}
diff --git a/Desktop.Shared/Reactive/ObservableObject.cs b/Desktop.Shared/Reactive/ObservableObject.cs
new file mode 100644
index 000000000..58c721379
--- /dev/null
+++ b/Desktop.Shared/Reactive/ObservableObject.cs
@@ -0,0 +1,45 @@
+using System.Collections.Concurrent;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace Remotely.Desktop.Shared.Reactive;
+
+public class ObservableObject : INotifyPropertyChanged
+{
+ private readonly ConcurrentDictionary _backingFields = new();
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ protected T? Get([CallerMemberName] string propertyName = "")
+ {
+ if (_backingFields.TryGetValue(propertyName, out var value) &&
+ value is T typedValue)
+ {
+ return typedValue;
+ }
+
+ return default;
+ }
+
+ protected T Get(T defaultValue, [CallerMemberName] string propertyName = "")
+ {
+ if (_backingFields.TryGetValue(propertyName, out var value) &&
+ value is T typedValue)
+ {
+ return typedValue;
+ }
+
+ return defaultValue;
+ }
+
+ protected void Set(T newValue, [CallerMemberName] string propertyName = "")
+ {
+ _backingFields.AddOrUpdate(propertyName, newValue, (k, v) => newValue);
+ NotifyPropertyChanged(propertyName);
+ }
+}
diff --git a/Desktop.Shared/Reactive/RelayCommand.cs b/Desktop.Shared/Reactive/RelayCommand.cs
new file mode 100644
index 000000000..c82a36b82
--- /dev/null
+++ b/Desktop.Shared/Reactive/RelayCommand.cs
@@ -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 Remotely.Desktop.Shared.Reactive;
+
+public class RelayCommand : ICommand
+{
+ private readonly Func _canExecute;
+ private readonly Action _execute;
+ public RelayCommand(Action execute)
+ {
+ _execute = execute;
+ _canExecute = () => true;
+ }
+
+ public RelayCommand(Action execute, Func 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 : ICommand
+{
+ private readonly Func _canExecute;
+ private readonly Action _execute;
+
+ public RelayCommand(Action execute)
+ {
+ _execute = execute;
+ _canExecute = (parameter) => true;
+ }
+
+ public RelayCommand(Action execute, Func 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);
+ }
+}
diff --git a/Desktop.Shared/Services/AppState.cs b/Desktop.Shared/Services/AppState.cs
new file mode 100644
index 000000000..522b29757
--- /dev/null
+++ b/Desktop.Shared/Services/AppState.cs
@@ -0,0 +1,188 @@
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Messages;
+using Remotely.Shared.Models;
+using Microsoft.Extensions.Logging;
+using Bitbound.SimpleMessenger;
+using System.Collections.Concurrent;
+
+namespace Remotely.Desktop.Shared.Services;
+
+public interface IAppState
+{
+ event EventHandler ScreenCastRequested;
+
+ event EventHandler ViewerAdded;
+
+ event EventHandler ViewerRemoved;
+ string AccessKey { get; }
+ Dictionary ArgDict { get; }
+ string Host { get; set; }
+ bool IsElevate { get; }
+ bool IsRelaunch { get; }
+ AppMode Mode { get; set; }
+ string OrganizationId { get; set; }
+ string OrganizationName { get; }
+ string PipeName { get; }
+ string[] RelaunchViewers { get; }
+ string RequesterName { get; }
+ string SessionId { get; }
+ ConcurrentDictionary 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 _argDict = new();
+ private readonly ILogger _logger;
+ private readonly IMessenger _messenger;
+ private string _host = string.Empty;
+
+ private bool _isConfigured;
+
+ public AppState(IMessenger messenger, ILogger logger)
+ {
+ _messenger = messenger;
+ _logger = logger;
+ }
+
+ public event EventHandler? ScreenCastRequested;
+
+ public event EventHandler? ViewerAdded;
+
+ public event EventHandler? ViewerRemoved;
+
+ public string AccessKey { get; private set; } = string.Empty;
+
+ public Dictionary 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 OrganizationId { get; set; } = string.Empty;
+ public string OrganizationName { get; private set; } = string.Empty;
+
+ public string PipeName { get; private set; } = string.Empty;
+ public string[] RelaunchViewers { get; private set; } = Array.Empty();
+ public string RequesterName { get; private set; } = string.Empty;
+ public string SessionId { get; private set; } = string.Empty;
+ public ConcurrentDictionary 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.");
+ }
+
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/BrandingProvider.cs b/Desktop.Shared/Services/BrandingProvider.cs
index 113cac48a..1d36a4d24 100644
--- a/Desktop.Shared/Services/BrandingProvider.cs
+++ b/Desktop.Shared/Services/BrandingProvider.cs
@@ -1,37 +1,41 @@
-using Immense.RemoteControl.Desktop.Shared.Abstractions;
-using Immense.RemoteControl.Desktop.Shared.Services;
-using Immense.RemoteControl.Shared;
-using Immense.RemoteControl.Shared.Models;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Remotely.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;
namespace Desktop.Shared.Services;
+public interface IBrandingProvider
+{
+ BrandingInfo CurrentBranding { get; }
+ Task Initialize();
+ void SetBrandingInfo(BrandingInfo brandingInfo);
+}
+
public class BrandingProvider : IBrandingProvider
{
private readonly IAppState _appState;
private readonly IEmbeddedServerDataProvider _embeddedDataSearcher;
private readonly ILogger _logger;
- private readonly IOrganizationIdProvider _orgIdProvider;
- private BrandingInfoBase? _brandingInfo;
+ private BrandingInfo? _brandingInfo;
public BrandingProvider(
IAppState appState,
- IOrganizationIdProvider orgIdProvider,
IEmbeddedServerDataProvider embeddedServerDataSearcher,
ILogger logger)
{
_appState = appState;
- _orgIdProvider = orgIdProvider;
_embeddedDataSearcher = embeddedServerDataSearcher;
_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()
@@ -56,9 +60,9 @@ public async Task Initialize()
};
}
- if (_brandingInfo.Icon?.Any() != true)
+ if (_brandingInfo.Icon is not { Length: > 0 })
{
- using var mrs = typeof(BrandingProvider).Assembly.GetManifestResourceStream("Desktop.Shared.Assets.Remotely_Icon.png");
+ using var mrs = typeof(BrandingProvider).Assembly.GetManifestResourceStream("Remotely.Desktop.Shared.Assets.Remotely_Icon.png");
using var ms = new MemoryStream();
mrs!.CopyTo(ms);
@@ -66,7 +70,7 @@ public async Task Initialize()
}
}
- public void SetBrandingInfo(BrandingInfoBase brandingInfo)
+ public void SetBrandingInfo(BrandingInfo brandingInfo)
{
_brandingInfo = brandingInfo;
}
@@ -75,7 +79,7 @@ private async Task> TryGetBrandingInfo()
{
try
{
- if (string.IsNullOrWhiteSpace(_orgIdProvider.OrganizationId) ||
+ if (string.IsNullOrWhiteSpace(_appState.OrganizationId) ||
string.IsNullOrWhiteSpace(_appState.Host))
{
var filePath = Process.GetCurrentProcess()?.MainModule?.FileName;
@@ -87,21 +91,24 @@ private async Task> TryGetBrandingInfo()
var result = _embeddedDataSearcher.TryGetEmbeddedData(filePath);
- if (!result.IsSuccess)
- {
- return result.HadException ?
- Result.Fail(result.Exception) :
- Result.Fail(result.Reason);
- }
-
- if (!string.IsNullOrWhiteSpace(result.Value.OrganizationId))
+ if (result.IsSuccess)
{
- _orgIdProvider.OrganizationId = result.Value.OrganizationId;
+ if (!string.IsNullOrWhiteSpace(result.Value.OrganizationId))
+ {
+ _appState.OrganizationId = result.Value.OrganizationId;
+ }
+
+ if (result.Value.ServerUrl is not null)
+ {
+ _appState.Host = result.Value.ServerUrl.AbsoluteUri;
+ }
}
- if (result.Value.ServerUrl is not null)
+ if (string.IsNullOrWhiteSpace(_appState.Host))
{
- _appState.Host = result.Value.ServerUrl.AbsoluteUri;
+ return result.HadException ?
+ Result.Fail(result.Exception) :
+ Result.Fail(result.Reason);
}
}
@@ -112,7 +119,7 @@ private async Task> TryGetBrandingInfo()
using var httpClient = new HttpClient();
- var brandingUrl = $"{_appState.Host.TrimEnd('/')}/api/branding/{_orgIdProvider.OrganizationId}";
+ var brandingUrl = $"{_appState.Host.TrimEnd('/')}/api/branding/{_appState.OrganizationId}";
var httpResult = await httpClient.GetFromJsonAsync(brandingUrl).ConfigureAwait(false);
if (httpResult is null)
{
diff --git a/Desktop.Shared/Services/ChatHostService.cs b/Desktop.Shared/Services/ChatHostService.cs
new file mode 100644
index 000000000..d1780b3a6
--- /dev/null
+++ b/Desktop.Shared/Services/ChatHostService.cs
@@ -0,0 +1,88 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Models;
+using System.IO.Pipes;
+using System.Text.Json;
+
+namespace Remotely.Desktop.Shared.Services;
+
+public interface IChatHostService
+{
+ Task StartChat(string requesterID, string organizationName);
+}
+public class ChatHostService : IChatHostService
+{
+ private readonly IChatUiService _chatUiService;
+ private readonly ILogger _logger;
+
+ private NamedPipeServerStream? _namedPipeStream;
+ private StreamReader? _reader;
+ private StreamWriter? _writer;
+
+ public ChatHostService(IChatUiService chatUiService, ILogger 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(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.");
+ }
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/DesktopEnvironment.cs b/Desktop.Shared/Services/DesktopEnvironment.cs
new file mode 100644
index 000000000..ae7c796ca
--- /dev/null
+++ b/Desktop.Shared/Services/DesktopEnvironment.cs
@@ -0,0 +1,43 @@
+using Remotely.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;
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/DesktopHubConnection.cs b/Desktop.Shared/Services/DesktopHubConnection.cs
new file mode 100644
index 000000000..1185e094d
--- /dev/null
+++ b/Desktop.Shared/Services/DesktopHubConnection.cs
@@ -0,0 +1,461 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Messages;
+using Remotely.Desktop.Shared.Native.Windows;
+using Remotely.Shared.Enums;
+using Remotely.Shared.Interfaces;
+using Remotely.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 Remotely.Desktop.Shared.Services;
+
+public interface IDesktopHubConnection
+{
+ HubConnection? Connection { get; }
+ HubConnectionState ConnectionState { get; }
+ bool IsConnected { get; }
+
+ Task> CheckRoundtripLatency(string viewerConnectionId);
+ Task Connect(TimeSpan timeout, CancellationToken cancellationToken);
+ Task Disconnect();
+ Task DisconnectAllViewers();
+ Task DisconnectViewer(IViewer viewer, bool notifyViewer);
+ Task GetSessionID();
+ Task NotifyRequesterUnattendedReady();
+ Task NotifyViewersRelaunchedScreenCasterReady(string[] viewerIDs);
+ Task SendAttendedSessionInfo(string machineName);
+
+ Task SendConnectionFailedToViewers(List viewerIDs);
+ Task SendConnectionRequestDenied(string viewerID);
+ Task SendDtoToViewer(T dto, string viewerId);
+
+ Task SendMessageToViewer(string viewerID, string message);
+ Task SendUnattendedSessionInfo(string sessionId, string accessKey, string machineName, string requesterName, string organizationName);
+}
+
+public class DesktopHubConnection : IDesktopHubConnection, IDesktopHubClient
+{
+ private readonly IAppState _appState;
+
+ private readonly ILogger _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 logger)
+ {
+ _messageHandler = messageHandler;
+ _remoteControlAccessService = remoteControlAccessService;
+ _serviceProvider = serviceProvider;
+ _appState = appState;
+ _logger = logger;
+
+ messenger.Register(this, HandleWindowsSessionEnding);
+ messenger.Register(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> CheckRoundtripLatency(string viewerConnectionId)
+ {
+ try
+ {
+ if (Connection is null)
+ {
+ return Result.Fail("Connection is not yet established.");
+ }
+ var sw = Stopwatch.StartNew();
+ var result = await Connection.InvokeAsync>("PingViewer", viewerConnectionId);
+ if (result.IsSuccess)
+ {
+ return Result.Ok(sw.Elapsed);
+ }
+ return Result.Fail("Latency check failed.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to check latency.");
+ return Result.Fail("An error occurred while checking latency.");
+ }
+ }
+
+ public async Task 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();
+ 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 GetSessionID()
+ {
+ if (Connection is null)
+ {
+ return string.Empty;
+ }
+
+ return await Connection.InvokeAsync("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 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 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 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 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("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(nameof(Disconnect), Disconnect);
+ connection.On(nameof(GetScreenCast), GetScreenCast);
+ connection.On(nameof(RequestScreenCast), RequestScreenCast);
+ connection.On(nameof(SendDtoToClient), SendDtoToClient);
+ connection.On(nameof(ViewerDisconnected), ViewerDisconnected);
+ connection.On(nameof(PromptForAccess), PromptForAccess);
+ }
+
+ private Result BuildConnection()
+ {
+ try
+ {
+ if (!Uri.TryCreate(_appState.Host, UriKind.Absolute, out _))
+ {
+ return Result.Fail("Invalid server URI.");
+ }
+
+ var builder = _serviceProvider.GetRequiredService();
+
+ 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(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);
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/DtoMessageHandler.cs b/Desktop.Shared/Services/DtoMessageHandler.cs
new file mode 100644
index 000000000..944c1f2e4
--- /dev/null
+++ b/Desktop.Shared/Services/DtoMessageHandler.cs
@@ -0,0 +1,331 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Native.Windows;
+using Remotely.Shared.Helpers;
+using Remotely.Shared.Models.Dtos;
+using MessagePack;
+using Microsoft.Extensions.Logging;
+
+namespace Remotely.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 _logger;
+
+
+ public DtoMessageHandler(
+ IKeyboardMouseInput keyboardMouseInput,
+ IAudioCapturer audioCapturer,
+ IClipboardService clipboardService,
+ IFileTransferService fileTransferService,
+ ILogger logger)
+ {
+ _keyboardMouseInput = keyboardMouseInput;
+ _audioCapturer = audioCapturer;
+ _clipboardService = clipboardService;
+ _fileTransferService = fileTransferService;
+ _logger = logger;
+ }
+
+ public async Task ParseMessage(IViewer viewer, byte[] message)
+ {
+ try
+ {
+ var wrapper = MessagePackSerializer.Deserialize(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(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(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(wrapper, out var dto))
+ {
+ return;
+ }
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(dto.Timestamp);
+ viewer.SetLastFrameReceived(timestamp.ToLocalTime());
+ }
+ private void KeyDown(DtoWrapper wrapper)
+ {
+ if (!DtoChunker.TryComplete(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(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(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(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(wrapper, out var dto))
+ {
+ return;
+ }
+
+ _keyboardMouseInput.SendMouseMove(dto!.PercentX, dto.PercentY, viewer);
+ }
+
+ private void MouseUp(DtoWrapper wrapper, IViewer viewer)
+ {
+ if (!DtoChunker.TryComplete(wrapper, out var dto))
+ {
+ return;
+ }
+
+ _keyboardMouseInput.SendMouseButtonAction(dto!.Button, ButtonAction.Up, dto.PercentX, dto.PercentY, viewer);
+ }
+
+ private void MouseWheel(DtoWrapper wrapper)
+ {
+ if (!DtoChunker.TryComplete(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(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(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(wrapper, out var dto))
+ {
+ return;
+ }
+
+ _audioCapturer.ToggleAudio(dto!.ToggleOn);
+ }
+
+ private void ToggleBlockInput(DtoWrapper wrapper)
+ {
+ if (!DtoChunker.TryComplete(wrapper, out var dto))
+ {
+ return;
+ }
+ _keyboardMouseInput.ToggleBlockInput(dto!.ToggleOn);
+ }
+}
diff --git a/Desktop.Shared/Services/IdleTimer.cs b/Desktop.Shared/Services/IdleTimer.cs
new file mode 100644
index 000000000..b7b948ed8
--- /dev/null
+++ b/Desktop.Shared/Services/IdleTimer.cs
@@ -0,0 +1,96 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.Logging;
+using System.Timers;
+
+namespace Remotely.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 _logger;
+ private readonly SemaphoreSlim _elapseLock = new(1, 1);
+ private System.Timers.Timer? _timer;
+
+ public IdleTimer(
+ IAppState appState,
+ IRemoteControlAccessService accessService,
+ IDesktopHubConnection desktopHubConnection,
+ IShutdownService shutdownService,
+ ILogger 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();
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/ImageHelper.cs b/Desktop.Shared/Services/ImageHelper.cs
new file mode 100644
index 000000000..fe69dc7a5
--- /dev/null
+++ b/Desktop.Shared/Services/ImageHelper.cs
@@ -0,0 +1,212 @@
+using Remotely.Desktop.Shared.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.IO;
+using Remotely.Shared.Primitives;
+using SkiaSharp;
+
+namespace Remotely.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 GetImageDiff(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false);
+}
+
+public class ImageHelper : IImageHelper
+{
+ private static readonly RecyclableMemoryStreamManager _recycleManager = new();
+ private readonly ILogger _logger;
+
+ public ImageHelper(ILogger 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 GetImageDiff(SKBitmap currentFrame, SKBitmap? previousFrame, bool forceFullscreen = false)
+ {
+ try
+ {
+ if (currentFrame is null)
+ {
+ return Result.Fail("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("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("No difference found.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while getting image diff.");
+ return Result.Fail(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;
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/OrganizationIdProvider.cs b/Desktop.Shared/Services/OrganizationIdProvider.cs
deleted file mode 100644
index 408824a11..000000000
--- a/Desktop.Shared/Services/OrganizationIdProvider.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Desktop.Shared.Services;
-
-public interface IOrganizationIdProvider
-{
- string OrganizationId { get; set; }
-}
-public class OrganizationIdProvider : IOrganizationIdProvider
-{
- public string OrganizationId { get; set; } = string.Empty;
-}
diff --git a/Desktop.Shared/Services/ScreenCaster.cs b/Desktop.Shared/Services/ScreenCaster.cs
new file mode 100644
index 000000000..ec9040c54
--- /dev/null
+++ b/Desktop.Shared/Services/ScreenCaster.cs
@@ -0,0 +1,283 @@
+using Microsoft.Extensions.DependencyInjection;
+using SkiaSharp;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Shared.Models;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Helpers;
+using Remotely.Shared.Models.Dtos;
+using MessagePack;
+using Remotely.Shared.Services;
+using Microsoft.IO;
+using System.Diagnostics;
+using Bitbound.SimpleMessenger;
+using Remotely.Desktop.Shared.Messages;
+
+namespace Remotely.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 _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 logger)
+ {
+ _appState = appState;
+ _cursorIconWatcher = cursorIconWatcher;
+ _sessionIndicator = sessionIndicator;
+ _shutdownService = shutdownService;
+ _imageHelper = imageHelper;
+ _systemTime = systemTime;
+ _viewerFactory = viewerFactory;
+ _logger = logger;
+
+ _messengerRegistrations =
+ [
+ messenger.Register(this, HandleWindowsSessionSwitchedMessage),
+ messenger.Register(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 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);
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/Viewer.cs b/Desktop.Shared/Services/Viewer.cs
new file mode 100644
index 000000000..d16f00ed9
--- /dev/null
+++ b/Desktop.Shared/Services/Viewer.cs
@@ -0,0 +1,374 @@
+using System.Collections.Concurrent;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Models;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Helpers;
+using Remotely.Shared.Models.Dtos;
+using Remotely.Desktop.Shared.ViewModels;
+using Microsoft.AspNetCore.SignalR.Client;
+using Remotely.Shared.Services;
+using Remotely.Desktop.Shared.Native.Windows;
+
+namespace Remotely.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 asyncEnumerable, Guid streamId);
+ Task SendFile(FileUpload fileUpload, Action progressUpdateCallback, CancellationToken cancelToken);
+ Task SendScreenData(string selectedDisplay, IEnumerable displayNames, int screenWidth, int screenHeight);
+ Task SendScreenSize(int width, int height);
+ Task SendSessionMetrics(SessionMetricsDto metrics);
+ Task SendWindowsSessions();
+ void SetLastFrameReceived(DateTimeOffset timestamp);
+ Task 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 _fpsQueue = new();
+ private readonly ILogger _logger;
+ private readonly ConcurrentQueue _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 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 stream, Guid streamId)
+ {
+ if (_desktopHubConnection.Connection is not null)
+ {
+ await _desktopHubConnection.Connection.SendAsync("SendDesktopStream", stream, streamId);
+ }
+ }
+
+ public async Task SendFile(
+ FileUpload fileUpload,
+ Action 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 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 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 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);
+ }
+ }
+}
diff --git a/Desktop.Shared/Services/ViewerFactory.cs b/Desktop.Shared/Services/ViewerFactory.cs
new file mode 100644
index 000000000..952f95318
--- /dev/null
+++ b/Desktop.Shared/Services/ViewerFactory.cs
@@ -0,0 +1,46 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.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 Remotely.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();
+ var screenCapturer = _serviceProvider.GetRequiredService();
+ var clipboardService = _serviceProvider.GetRequiredService();
+ var audioCapturer = _serviceProvider.GetRequiredService();
+ var systemTime = _serviceProvider.GetRequiredService();
+ var logger = _serviceProvider.GetRequiredService>();
+
+ return new Viewer(
+ viewerName,
+ viewerConnectionId,
+ desktopHubConnection,
+ screenCapturer,
+ clipboardService,
+ audioCapturer,
+ systemTime,
+ logger);
+ }
+}
diff --git a/Desktop.Shared/Startup/CommandProvider.cs b/Desktop.Shared/Startup/CommandProvider.cs
new file mode 100644
index 000000000..281051911
--- /dev/null
+++ b/Desktop.Shared/Startup/CommandProvider.cs
@@ -0,0 +1,99 @@
+using System.CommandLine;
+using CommunityToolkit.Diagnostics;
+using Remotely.Desktop.Shared.Enums;
+
+namespace Remotely.Desktop.Shared.Startup;
+public static class CommandProvider
+{
+ ///
+ /// Creates a for starting the remote control client.
+ ///
+ /// Whether to create a or .
+ /// The description for the command.
+ /// The name used to invoke the command. Required if not a root command.
+ ///
+ 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(
+ new[] { "-h", "--host" },
+ "The hostname of the server to which to connect (e.g. https://example.com).");
+ rootCommand.AddOption(hostOption);
+
+ var modeOption = new Option(
+ new[] { "-m", "--mode" },
+ () => AppMode.Attended,
+ "The remote control mode to use. Either Attended, Unattended, or Chat.");
+ rootCommand.AddOption(modeOption);
+
+
+ var pipeNameOption = new Option(
+ 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()))
+ {
+ context.ErrorMessage = "A pipe name must be specified when AppMode is Chat.";
+ }
+ });
+ rootCommand.AddOption(pipeNameOption);
+
+ var sessionIdOption = new Option(
+ 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(
+ new[] { "-a", "--access-key" },
+ "In Unattended mode, secures access to the connection using the provided key.");
+ rootCommand.AddOption(accessKeyOption);
+
+ var requesterNameOption = new Option(
+ new[] { "-r", "--requester-name" },
+ "The name of the technician requesting to connect.");
+ rootCommand.AddOption(requesterNameOption);
+
+ var organizationNameOption = new Option(
+ new[] { "-o", "--org-name" },
+ "The organization name of the technician requesting to connect.");
+ rootCommand.AddOption(organizationNameOption);
+
+ var relaunchOption = new Option(
+ "--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(
+ "--viewers",
+ "Used with --relaunch. Should be a comma-separated list of viewers' " +
+ "SignalR connection IDs.");
+ rootCommand.AddOption(viewersOption);
+
+ var elevateOption = new Option(
+ "--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;
+ }
+}
diff --git a/Desktop.Shared/Startup/IServiceCollectionExtensions.cs b/Desktop.Shared/Startup/IServiceCollectionExtensions.cs
new file mode 100644
index 000000000..7d5f1fe81
--- /dev/null
+++ b/Desktop.Shared/Startup/IServiceCollectionExtensions.cs
@@ -0,0 +1,36 @@
+using Remotely.Desktop.Shared.Services;
+using Remotely.Shared.Services;
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Bitbound.SimpleMessenger;
+using Desktop.Shared.Services;
+using Remotely.Desktop.Shared.Abstractions;
+
+namespace Remotely.Desktop.Shared.Startup;
+
+public static class IServiceCollectionExtensions
+{
+ internal static void AddRemoteControlXplat(
+ this IServiceCollection services)
+ {
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole().AddDebug();
+ });
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(s => WeakReferenceMessenger.Default);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddTransient();
+ services.AddTransient(s => new HubConnectionBuilder());
+ }
+}
diff --git a/Desktop.Shared/Startup/IServiceProviderExtensions.cs b/Desktop.Shared/Startup/IServiceProviderExtensions.cs
new file mode 100644
index 000000000..de8e3ed37
--- /dev/null
+++ b/Desktop.Shared/Startup/IServiceProviderExtensions.cs
@@ -0,0 +1,163 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Enums;
+using Remotely.Desktop.Shared.Native.Windows;
+using Remotely.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 Remotely.Desktop.Shared.Startup;
+
+public static class IServiceProviderExtensions
+{
+ ///
+ /// Runs the remote control startup with the specified arguments.
+ ///
+ public static async Task 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>();
+ TaskScheduler.UnobservedTaskException += (object? sender, UnobservedTaskExceptionEventArgs e) =>
+ {
+ HandleUnobservedTask(e, logger);
+ };
+
+ if (OperatingSystem.IsWindows() && elevate)
+ {
+ RelaunchElevated();
+ return Result.Ok();
+ }
+
+ var appState = services.GetRequiredService();
+ 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();
+ await appStartup.Run();
+ return Result.Ok();
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail(ex);
+ }
+
+ }
+
+ ///
+ /// Runs the remote control startup as a root command. This uses the System.CommandLine package.
+ ///
+ /// The service provider fo rthe app using this library.
+ /// The original command line arguments passed into the app.
+ /// The description to use for the remote control command.
+ /// If provided, will be used as a fallback if --host option is missing.
+ ///
+ public static async Task 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 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);
+ }
+}
diff --git a/Desktop.Shared/StaticServiceProvider.cs b/Desktop.Shared/StaticServiceProvider.cs
new file mode 100644
index 000000000..8b7be2efa
--- /dev/null
+++ b/Desktop.Shared/StaticServiceProvider.cs
@@ -0,0 +1,6 @@
+namespace Remotely.Desktop.Shared;
+
+public static class StaticServiceProvider
+{
+ public static IServiceProvider? Instance { get; set; }
+}
diff --git a/Desktop.Shared/ViewModels/FileUpload.cs b/Desktop.Shared/ViewModels/FileUpload.cs
new file mode 100644
index 000000000..0ab05fbd1
--- /dev/null
+++ b/Desktop.Shared/ViewModels/FileUpload.cs
@@ -0,0 +1,23 @@
+using Remotely.Desktop.Shared.Reactive;
+
+namespace Remotely.Desktop.Shared.ViewModels;
+
+public partial class FileUpload : ObservableObject
+{
+ public string FilePath
+ {
+ get => Get(defaultValue: string.Empty);
+ set => Set(value);
+ }
+
+
+ public double PercentProgress
+ {
+ get => Get();
+ set => Set(value);
+ }
+
+ public CancellationTokenSource CancellationTokenSource { get; } = new CancellationTokenSource();
+
+ public string DisplayName => Path.GetFileName(FilePath);
+}
diff --git a/Desktop.UI/App.axaml b/Desktop.UI/App.axaml
new file mode 100644
index 000000000..0f69af773
--- /dev/null
+++ b/Desktop.UI/App.axaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Desktop.UI/App.axaml.cs b/Desktop.UI/App.axaml.cs
new file mode 100644
index 000000000..f11a65ff3
--- /dev/null
+++ b/Desktop.UI/App.axaml.cs
@@ -0,0 +1,32 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core.Plugins;
+using Avalonia.Markup.Xaml;
+
+namespace Remotely.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();
+ }
+}
diff --git a/Desktop.UI/Assets/DefaultIcon.ico b/Desktop.UI/Assets/DefaultIcon.ico
new file mode 100644
index 000000000..55aaa8ca3
Binary files /dev/null and b/Desktop.UI/Assets/DefaultIcon.ico differ
diff --git a/Desktop.UI/Assets/DefaultIcon.png b/Desktop.UI/Assets/DefaultIcon.png
new file mode 100644
index 000000000..987309020
Binary files /dev/null and b/Desktop.UI/Assets/DefaultIcon.png differ
diff --git a/Desktop.UI/Assets/Gear.png b/Desktop.UI/Assets/Gear.png
new file mode 100644
index 000000000..bb38d5969
Binary files /dev/null and b/Desktop.UI/Assets/Gear.png differ
diff --git a/Desktop.UI/Assets/avalonia-logo.ico b/Desktop.UI/Assets/avalonia-logo.ico
new file mode 100644
index 000000000..da8d49ff9
Binary files /dev/null and b/Desktop.UI/Assets/avalonia-logo.ico differ
diff --git a/Desktop.UI/Controls/Dialogs/MessageBox.axaml b/Desktop.UI/Controls/Dialogs/MessageBox.axaml
new file mode 100644
index 000000000..2606d6a13
--- /dev/null
+++ b/Desktop.UI/Controls/Dialogs/MessageBox.axaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Desktop.UI/Controls/Dialogs/MessageBox.axaml.cs b/Desktop.UI/Controls/Dialogs/MessageBox.axaml.cs
new file mode 100644
index 000000000..9c4fab839
--- /dev/null
+++ b/Desktop.UI/Controls/Dialogs/MessageBox.axaml.cs
@@ -0,0 +1,51 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using CommunityToolkit.Diagnostics;
+using Remotely.Desktop.Shared;
+using Microsoft.Extensions.DependencyInjection;
+using System.Threading;
+
+namespace Remotely.Desktop.UI.Controls.Dialogs;
+
+public partial class MessageBox : Window
+{
+ public MessageBox()
+ {
+ InitializeComponent();
+ }
+
+ public static async Task Show(string message, string caption, MessageBoxType type)
+ {
+ Guard.IsNotNull(StaticServiceProvider.Instance, nameof(StaticServiceProvider.Instance));
+
+ var dispatcher = StaticServiceProvider.Instance.GetRequiredService();
+
+ return await dispatcher.InvokeAsync(async () =>
+ {
+ var viewModel = StaticServiceProvider.Instance.GetRequiredService();
+ 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;
+ });
+ }
+}
\ No newline at end of file
diff --git a/Desktop.UI/Controls/Dialogs/MessageBoxResult.cs b/Desktop.UI/Controls/Dialogs/MessageBoxResult.cs
new file mode 100644
index 000000000..6b582390a
--- /dev/null
+++ b/Desktop.UI/Controls/Dialogs/MessageBoxResult.cs
@@ -0,0 +1,9 @@
+namespace Remotely.Desktop.UI.Controls.Dialogs;
+
+public enum MessageBoxResult
+{
+ Cancel,
+ OK,
+ Yes,
+ No
+}
diff --git a/Desktop.UI/Controls/Dialogs/MessageBoxType.cs b/Desktop.UI/Controls/Dialogs/MessageBoxType.cs
new file mode 100644
index 000000000..80a4f0bac
--- /dev/null
+++ b/Desktop.UI/Controls/Dialogs/MessageBoxType.cs
@@ -0,0 +1,7 @@
+namespace Remotely.Desktop.UI.Controls.Dialogs;
+
+public enum MessageBoxType
+{
+ OK,
+ YesNo
+}
diff --git a/Desktop.UI/Desktop.UI.csproj b/Desktop.UI/Desktop.UI.csproj
new file mode 100644
index 000000000..fe86b4a4a
--- /dev/null
+++ b/Desktop.UI/Desktop.UI.csproj
@@ -0,0 +1,51 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ copyused
+ true
+ true
+ true
+ false
+ Remotely.Desktop.UI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Desktop.UI/GlobalUsings.cs b/Desktop.UI/GlobalUsings.cs
new file mode 100644
index 000000000..fb2edcb29
--- /dev/null
+++ b/Desktop.UI/GlobalUsings.cs
@@ -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 Remotely.Desktop.UI.Services;
+global using Remotely.Desktop.UI.ViewModels;
+global using Remotely.Desktop.UI.Views;
\ No newline at end of file
diff --git a/Desktop.UI/Services/ChatUiService.cs b/Desktop.UI/Services/ChatUiService.cs
new file mode 100644
index 000000000..2c6a0fe0b
--- /dev/null
+++ b/Desktop.UI/Services/ChatUiService.cs
@@ -0,0 +1,67 @@
+using Avalonia.Controls;
+using Remotely.Desktop.Shared.Abstractions;
+using System.ComponentModel;
+using Remotely.Desktop.UI.Controls.Dialogs;
+using CommunityToolkit.Diagnostics;
+using Remotely.Shared.Models;
+
+namespace Remotely.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);
+ }
+}
diff --git a/Desktop.UI/Services/ClipboardService.cs b/Desktop.UI/Services/ClipboardService.cs
new file mode 100644
index 000000000..6d07edf80
--- /dev/null
+++ b/Desktop.UI/Services/ClipboardService.cs
@@ -0,0 +1,88 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using System.Threading;
+
+namespace Remotely.Desktop.UI.Services;
+
+public class ClipboardService : IClipboardService
+{
+ private readonly IUiDispatcher _dispatcher;
+ private readonly ILogger _logger;
+ private Task? _watcherTask;
+
+ public event EventHandler? ClipboardTextChanged;
+
+ public ClipboardService(
+ IUiDispatcher dispatcher,
+ ILogger 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);
+ }
+ }
+ }
+}
diff --git a/Desktop.UI/Services/DialogProvider.cs b/Desktop.UI/Services/DialogProvider.cs
new file mode 100644
index 000000000..cbc01d199
--- /dev/null
+++ b/Desktop.UI/Services/DialogProvider.cs
@@ -0,0 +1,22 @@
+using Remotely.Desktop.UI.Controls.Dialogs;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Remotely.Desktop.UI.Services;
+
+public interface IDialogProvider
+{
+ Task Show(string message, string caption, MessageBoxType type);
+}
+
+internal class DialogProvider : IDialogProvider
+{
+ public async Task Show(string message, string caption, MessageBoxType type)
+ {
+ return await MessageBox.Show(message, caption, type);
+ }
+}
diff --git a/Desktop.UI/Services/RemoteControlAccessService.cs b/Desktop.UI/Services/RemoteControlAccessService.cs
new file mode 100644
index 000000000..167d25b4a
--- /dev/null
+++ b/Desktop.UI/Services/RemoteControlAccessService.cs
@@ -0,0 +1,62 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Shared.Enums;
+using Microsoft.Extensions.Logging;
+using System.Threading;
+
+namespace Remotely.Desktop.UI.Services;
+
+public class RemoteControlAccessService : IRemoteControlAccessService
+{
+ private readonly IViewModelFactory _viewModelFactory;
+ private readonly IUiDispatcher _dispatcher;
+ private readonly ILogger _logger;
+ private volatile int _promptCount = 0;
+
+ public RemoteControlAccessService(
+ IViewModelFactory viewModelFactory,
+ IUiDispatcher dispatcher,
+ ILogger logger)
+ {
+ _viewModelFactory = viewModelFactory;
+ _dispatcher = dispatcher;
+ _logger = logger;
+ }
+
+ public bool IsPromptOpen => _promptCount > 0;
+
+ public async Task 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);
+ }
+ });
+ }
+}
diff --git a/Desktop.UI/Services/SessionIndicator.cs b/Desktop.UI/Services/SessionIndicator.cs
new file mode 100644
index 000000000..42d35aa2b
--- /dev/null
+++ b/Desktop.UI/Services/SessionIndicator.cs
@@ -0,0 +1,26 @@
+using Remotely.Desktop.Shared;
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Remotely.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()
+ };
+ _dispatcher.ShowMainWindow(indicatorWindow);
+ });
+ }
+}
diff --git a/Desktop.UI/Services/UiDispatcher.cs b/Desktop.UI/Services/UiDispatcher.cs
new file mode 100644
index 000000000..35b30834a
--- /dev/null
+++ b/Desktop.UI/Services/UiDispatcher.cs
@@ -0,0 +1,255 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input.Platform;
+using Avalonia.Threading;
+using Remotely.Shared.Helpers;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Primitives;
+using System.Threading;
+
+namespace Remotely.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 func, DispatcherPriority priority = default);
+ Task InvokeAsync(Func> func, DispatcherPriority priority = default);
+ void Post(Action action, DispatcherPriority priority = default);
+ Task Show(Window window, TimeSpan timeout);
+
+ Task ShowDialog(Window window);
+ void ShowMainWindow(Window window);
+
+ void ShowWindow(Window window);
+ void Shutdown();
+ void StartClassicDesktop();
+ Task StartHeadless();
+}
+
+internal class UiDispatcher : IUiDispatcher
+{
+ private static readonly CancellationTokenSource _appCts = new();
+ private static Application? _currentApp;
+ private readonly ILogger _logger;
+ private AppBuilder? _appBuilder;
+ private Window? _headlessMainWindow;
+
+ public UiDispatcher(ILogger 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 func, DispatcherPriority priority = default)
+ {
+
+ return Dispatcher.UIThread.InvokeAsync(func, priority);
+ }
+
+ public Task InvokeAsync(Func> 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 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 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()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+
+ private static void RunHeadless(Application app, string[] args)
+ {
+ _currentApp = app;
+ app.Run(_appCts.Token);
+ }
+}
diff --git a/Desktop.UI/Services/ViewModelFactory.cs b/Desktop.UI/Services/ViewModelFactory.cs
new file mode 100644
index 000000000..be9157613
--- /dev/null
+++ b/Desktop.UI/Services/ViewModelFactory.cs
@@ -0,0 +1,61 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.IO;
+using Desktop.Shared.Services;
+
+namespace Remotely.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();
+ var dispatcher = _serviceProvider.GetRequiredService();
+ var logger = _serviceProvider.GetRequiredService>();
+ return new ChatWindowViewModel(streamWriter, organizationName, branding, dispatcher, logger);
+ }
+
+ public IFileTransferWindowViewModel CreateFileTransferWindowViewModel(IViewer viewer)
+ {
+ var brandingProvider = _serviceProvider.GetRequiredService();
+ var dispatcher = _serviceProvider.GetRequiredService();
+ var logger = _serviceProvider.GetRequiredService>();
+ var fileTransfer = _serviceProvider.GetRequiredService();
+ return new FileTransferWindowViewModel(viewer, brandingProvider, dispatcher, fileTransfer, logger);
+ }
+
+ public IPromptForAccessWindowViewModel CreatePromptForAccessViewModel(string requesterName, string organizationName)
+ {
+ var brandingProvider = _serviceProvider.GetRequiredService();
+ var dispatcher = _serviceProvider.GetRequiredService();
+ var logger = _serviceProvider.GetRequiredService>();
+ return new PromptForAccessWindowViewModel(requesterName, organizationName, brandingProvider, dispatcher, logger);
+ }
+
+ public IHostNamePromptViewModel CreateHostNamePromptViewModel()
+ {
+ var brandingProvider = _serviceProvider.GetRequiredService();
+ var dispatcher = _serviceProvider.GetRequiredService();
+ var logger = _serviceProvider.GetRequiredService>();
+ return new HostNamePromptViewModel(brandingProvider, dispatcher, logger);
+ }
+}
diff --git a/Desktop.UI/Startup/IServiceCollectionExtensions.cs b/Desktop.UI/Startup/IServiceCollectionExtensions.cs
new file mode 100644
index 000000000..a227db4bf
--- /dev/null
+++ b/Desktop.UI/Startup/IServiceCollectionExtensions.cs
@@ -0,0 +1,23 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Remotely.Desktop.UI.Startup;
+
+public static class IServiceCollectionExtensions
+{
+ public static void AddRemoteControlUi(
+ this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddTransient();
+ services.AddSingleton();
+ }
+}
diff --git a/Desktop.UI/ViewModels/BrandedViewModelBase.cs b/Desktop.UI/ViewModels/BrandedViewModelBase.cs
new file mode 100644
index 000000000..927d4f882
--- /dev/null
+++ b/Desktop.UI/ViewModels/BrandedViewModelBase.cs
@@ -0,0 +1,93 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Reactive;
+using Remotely.Shared.Models;
+using Microsoft.Extensions.Logging;
+using Remotely.Shared.Entities;
+using System.IO;
+using System.Reflection;
+using Desktop.Shared.Services;
+
+namespace Remotely.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 _logger;
+ protected readonly IUiDispatcher _dispatcher;
+ private readonly IBrandingProvider _brandingProvider;
+
+
+ public BrandedViewModelBase(
+ IBrandingProvider brandingProvider,
+ IUiDispatcher dispatcher,
+ ILogger logger)
+ {
+ _brandingProvider = brandingProvider;
+ _dispatcher = dispatcher;
+ _logger = logger;
+
+ ApplyBrandingImpl();
+ }
+
+ public Bitmap? Icon
+ {
+ get => Get();
+ set => Set(value);
+ }
+
+ public string ProductName
+ {
+ get => Get() ?? "Remote Control";
+ set => Set(value ?? "Remote Control");
+ }
+
+ public WindowIcon? WindowIcon
+ {
+ get => Get();
+ set => Set(value);
+ }
+
+ private void ApplyBrandingImpl()
+ {
+ _dispatcher.Invoke(() =>
+ {
+ try
+ {
+ _brandingInfo ??= _brandingProvider.CurrentBranding;
+
+ ProductName = _brandingInfo.Product;
+
+ if (_brandingInfo.Icon is { Length: > 0 })
+ {
+ using var imageStream = new MemoryStream(_brandingInfo.Icon);
+ Icon = new Bitmap(imageStream);
+ }
+ else
+ {
+ using var imageStream =
+ Assembly
+ .GetExecutingAssembly()
+ .GetManifestResourceStream("Remotely.Desktop.Shared.Assets.DefaultIcon.png") ?? new MemoryStream();
+
+ Icon = new Bitmap(imageStream);
+ }
+
+ WindowIcon = new WindowIcon(Icon);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error applying branding.");
+ }
+ });
+ }
+}
diff --git a/Desktop.UI/ViewModels/ChatWindowViewModel.cs b/Desktop.UI/ViewModels/ChatWindowViewModel.cs
new file mode 100644
index 000000000..04cdf4cba
--- /dev/null
+++ b/Desktop.UI/ViewModels/ChatWindowViewModel.cs
@@ -0,0 +1,116 @@
+using Avalonia.Controls;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Text.Json;
+using System.Windows.Input;
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Reactive;
+using Microsoft.Extensions.DependencyInjection;
+using Remotely.Shared.Models;
+using Desktop.Shared.Services;
+
+namespace Remotely.Desktop.UI.ViewModels;
+
+public interface IChatWindowViewModel : IBrandedViewModelBase
+{
+ ObservableCollection 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 logger)
+ : base(brandingProvider, dispatcher, logger)
+ {
+ _streamWriter = streamWriter;
+ if (!string.IsNullOrWhiteSpace(organizationName))
+ {
+ OrganizationName = organizationName;
+ }
+ CloseCommand = new RelayCommand(CloseWindow);
+ MinimizeCommand = new RelayCommand(MinimizeWindow);
+ }
+
+
+ public ObservableCollection ChatMessages { get; } = new ObservableCollection();
+
+ public string ChatSessionHeader => $"Chat session with {OrganizationName}";
+
+ public ICommand CloseCommand { get; }
+
+ public string InputText
+ {
+ get => Get() ?? string.Empty;
+ set => Set(value);
+ }
+
+ public ICommand MinimizeCommand { get; }
+
+ public string OrganizationName
+ {
+ get => Get() ?? "your IT provider";
+ set
+ {
+ Set(value);
+ NotifyPropertyChanged(nameof(ChatSessionHeader));
+ }
+ }
+
+ public string SenderName
+ {
+ get => Get() ?? "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;
+ }
+ }
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeBrandedViewModelBase.cs b/Desktop.UI/ViewModels/Fakes/FakeBrandedViewModelBase.cs
new file mode 100644
index 000000000..ece7a67b8
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeBrandedViewModelBase.cs
@@ -0,0 +1,51 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Remotely.Desktop.UI.Controls.Dialogs;
+using Remotely.Shared.Models;
+using Remotely.Shared.Entities;
+using System.Diagnostics;
+using System.IO;
+
+namespace Remotely.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("Remotely.Desktop.Shared.Assets.DefaultIcon.png") ?? new MemoryStream();
+
+ return new Bitmap(imageStream);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex.Message);
+ return null;
+ }
+ }
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeChatWindowViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeChatWindowViewModel.cs
new file mode 100644
index 000000000..35f934946
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeChatWindowViewModel.cs
@@ -0,0 +1,41 @@
+using Remotely.Desktop.Shared.Reactive;
+using Remotely.Shared.Models;
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+
+namespace Remotely.Desktop.UI.ViewModels.Fakes;
+
+public class FakeChatWindowViewModel : FakeBrandedViewModelBase, IChatWindowViewModel
+{
+ public ObservableCollection 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;
+ }
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeFileTransferViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeFileTransferViewModel.cs
new file mode 100644
index 000000000..e9951a97e
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeFileTransferViewModel.cs
@@ -0,0 +1,33 @@
+using Remotely.Desktop.Shared.Reactive;
+using Remotely.Desktop.Shared.ViewModels;
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+
+namespace Remotely.Desktop.UI.ViewModels.Fakes;
+
+public class FakeFileTransferViewModel : FakeBrandedViewModelBase, IFileTransferWindowViewModel
+{
+ public ObservableCollection 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;
+ }
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeHostNamePromptViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeHostNamePromptViewModel.cs
new file mode 100644
index 000000000..8917174ac
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeHostNamePromptViewModel.cs
@@ -0,0 +1,11 @@
+using Remotely.Desktop.Shared.Reactive;
+using System.Windows.Input;
+
+namespace Remotely.Desktop.UI.ViewModels.Fakes;
+
+public class FakeHostNamePromptViewModel : FakeBrandedViewModelBase, IHostNamePromptViewModel
+{
+ public string Host { get; set; } = "https://localhost:7024";
+
+ public ICommand OKCommand => new RelayCommand(() => { });
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeMainViewViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeMainViewViewModel.cs
new file mode 100644
index 000000000..7cd5ef4d5
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeMainViewViewModel.cs
@@ -0,0 +1,169 @@
+using Remotely.Desktop.Shared.Abstractions;
+using Remotely.Desktop.Shared.Reactive;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.ViewModels;
+using Remotely.Shared.Models;
+using Remotely.Shared.Models.Dtos;
+using System.Collections.ObjectModel;
+using System.Threading;
+using System.Windows.Input;
+
+namespace Remotely.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 Viewers { get; } = new() { new FakeViewer() };
+
+ public IList SelectedViewers { get; } = new List();
+
+ 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 asyncEnumerable, Guid streamId)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task SendFile(FileUpload fileUpload, Action progressUpdateCallback, CancellationToken cancelToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public Task SendScreenData(string selectedDisplay, IEnumerable 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 WaitForViewer()
+ {
+ return Task.FromResult(true);
+ }
+ }
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeMainWindowViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeMainWindowViewModel.cs
new file mode 100644
index 000000000..9b040d1bf
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeMainWindowViewModel.cs
@@ -0,0 +1,5 @@
+namespace Remotely.Desktop.UI.ViewModels.Fakes;
+
+public class FakeMainWindowViewModel : FakeBrandedViewModelBase, IMainWindowViewModel
+{
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeMessageBoxViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeMessageBoxViewModel.cs
new file mode 100644
index 000000000..013479435
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeMessageBoxViewModel.cs
@@ -0,0 +1,21 @@
+using Remotely.Desktop.Shared.Reactive;
+using System.Windows.Input;
+using Remotely.Desktop.UI.Controls.Dialogs;
+
+namespace Remotely.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(() => { });
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakePromptForAccessViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakePromptForAccessViewModel.cs
new file mode 100644
index 000000000..81cb758d3
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakePromptForAccessViewModel.cs
@@ -0,0 +1,23 @@
+using Remotely.Desktop.Shared.Reactive;
+using System.Windows.Input;
+
+namespace Remotely.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(() => { });
+}
diff --git a/Desktop.UI/ViewModels/Fakes/FakeSessionIndicatorWindowViewModel.cs b/Desktop.UI/ViewModels/Fakes/FakeSessionIndicatorWindowViewModel.cs
new file mode 100644
index 000000000..14aac159a
--- /dev/null
+++ b/Desktop.UI/ViewModels/Fakes/FakeSessionIndicatorWindowViewModel.cs
@@ -0,0 +1,8 @@
+namespace Remotely.Desktop.UI.ViewModels.Fakes;
+internal class FakeSessionIndicatorWindowViewModel : FakeBrandedViewModelBase, ISessionIndicatorWindowViewModel
+{
+ public Task PromptForExit()
+ {
+ return Task.CompletedTask;
+ }
+}
diff --git a/Desktop.UI/ViewModels/FileTransferWindowViewModel.cs b/Desktop.UI/ViewModels/FileTransferWindowViewModel.cs
new file mode 100644
index 000000000..98b1a4556
--- /dev/null
+++ b/Desktop.UI/ViewModels/FileTransferWindowViewModel.cs
@@ -0,0 +1,131 @@
+using Avalonia.Threading;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Desktop.Shared.ViewModels;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Windows.Input;
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Reactive;
+using Desktop.Shared.Services;
+
+namespace Remotely.Desktop.UI.ViewModels;
+
+public interface IFileTransferWindowViewModel : IBrandedViewModelBase
+{
+ ObservableCollection FileUploads { get; }
+ ICommand OpenFileUploadDialogCommand { get; }
+ ICommand RemoveFileUploadCommand { get; }
+ string ViewerConnectionId { get; set; }
+ string ViewerName { get; set; }
+
+ void RemoveFileUpload(FileUpload? fileUpload);
+ Task UploadFile(string filePath);
+}
+
+public class FileTransferWindowViewModel : BrandedViewModelBase, IFileTransferWindowViewModel
+{
+ private readonly IFileTransferService _fileTransferService;
+ private readonly IViewer _viewer;
+
+ public FileTransferWindowViewModel(
+ IViewer viewer,
+ IBrandingProvider brandingProvider,
+ IUiDispatcher dispatcher,
+ IFileTransferService fileTransferService,
+ ILogger logger)
+ : base(brandingProvider, dispatcher, logger)
+ {
+ _viewer = viewer;
+ _fileTransferService = fileTransferService;
+ ViewerName = viewer.Name;
+ ViewerConnectionId = viewer.ViewerConnectionId;
+
+ OpenFileUploadDialogCommand = new AsyncRelayCommand(OpenFileUploadDialog);
+ RemoveFileUploadCommand = new RelayCommand(RemoveFileUpload);
+ }
+
+ public ObservableCollection FileUploads { get; } = new ObservableCollection();
+
+ public ICommand OpenFileUploadDialogCommand { get; }
+
+ public ICommand RemoveFileUploadCommand { get; }
+
+ public string ViewerConnectionId
+ {
+ get => Get() ?? string.Empty;
+ set => Set(value);
+ }
+
+ public string ViewerName
+ {
+ get => Get() ?? string.Empty;
+ set => Set(value);
+ }
+
+ public void RemoveFileUpload(FileUpload? fileUpload)
+ {
+ if (fileUpload is null)
+ {
+ return;
+ }
+ FileUploads.Remove(fileUpload);
+ fileUpload.CancellationTokenSource.Cancel();
+ }
+
+ public async Task UploadFile(string filePath)
+ {
+ var fileUpload = new FileUpload()
+ {
+ FilePath = filePath
+ };
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ FileUploads.Add(fileUpload);
+ });
+
+ await _fileTransferService.UploadFile(
+ fileUpload,
+ _viewer,
+ async progress =>
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ fileUpload.PercentProgress = progress;
+ });
+ },
+ fileUpload.CancellationTokenSource.Token);
+ }
+
+ private async Task OpenFileUploadDialog(FileTransferWindow? window)
+ {
+ if (window is null)
+ {
+ return;
+ }
+
+ var initialDir = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
+ if (!Directory.Exists(initialDir))
+ {
+ initialDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "RemoteControl")).FullName;
+ }
+
+ var startFolder = await window.StorageProvider.TryGetFolderFromPathAsync(new Uri(initialDir));
+ var result = await window.StorageProvider.OpenFilePickerAsync(new()
+ {
+ Title = "Upload File via Remotely",
+ AllowMultiple = true,
+ SuggestedStartLocation = startFolder
+ });
+
+ if (result?.Any() != true)
+ {
+ return;
+ }
+ foreach (var file in result)
+ {
+ await UploadFile($"{file.Path.LocalPath}");
+ }
+ }
+}
diff --git a/Desktop.UI/ViewModels/HostNamePromptViewModel.cs b/Desktop.UI/ViewModels/HostNamePromptViewModel.cs
new file mode 100644
index 000000000..eefdeab96
--- /dev/null
+++ b/Desktop.UI/ViewModels/HostNamePromptViewModel.cs
@@ -0,0 +1,34 @@
+using Avalonia.Controls;
+using System.Windows.Input;
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Reactive;
+using Desktop.Shared.Services;
+
+namespace Remotely.Desktop.UI.ViewModels;
+
+public interface IHostNamePromptViewModel : IBrandedViewModelBase
+{
+ string Host { get; set; }
+ ICommand OKCommand { get; }
+}
+
+public class HostNamePromptViewModel : BrandedViewModelBase, IHostNamePromptViewModel
+{
+ public HostNamePromptViewModel(
+ IBrandingProvider brandingProvider,
+ IUiDispatcher dispatcher,
+ ILogger logger)
+ : base(brandingProvider, dispatcher, logger)
+ {
+ OKCommand = new RelayCommand(x => x?.Close());
+ }
+
+ public string Host
+ {
+ get => Get() ?? "https://";
+ set => Set(value);
+ }
+
+ public ICommand OKCommand { get; }
+}
diff --git a/Desktop.UI/ViewModels/MainViewViewModel.cs b/Desktop.UI/ViewModels/MainViewViewModel.cs
new file mode 100644
index 000000000..4e2cbf866
--- /dev/null
+++ b/Desktop.UI/ViewModels/MainViewViewModel.cs
@@ -0,0 +1,350 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Remotely.Desktop.Shared.Services;
+using Remotely.Shared.Models;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows.Input;
+using Remotely.Desktop.Shared.Abstractions;
+using Microsoft.Extensions.Logging;
+using Remotely.Desktop.Shared.Reactive;
+using Microsoft.Extensions.DependencyInjection;
+using Remotely.Desktop.UI.Controls.Dialogs;
+using Remotely.Desktop.Shared.Native.Linux;
+using Desktop.Shared.Services;
+
+namespace Remotely.Desktop.UI.ViewModels;
+
+public interface IMainViewViewModel : IBrandedViewModelBase
+{
+ AsyncRelayCommand ChangeServerCommand { get; }
+ AsyncRelayCommand CopyLinkCommand { get; }
+ double CopyMessageOpacity { get; set; }
+ string Host { get; set; }
+ bool IsCopyMessageVisible { get; set; }
+ ICommand OpenOptionsMenu { get; }
+ AsyncRelayCommand RemoveViewersCommand { get; }
+ IList SelectedViewers { get; }
+ string StatusMessage { get; set; }
+ ObservableCollection Viewers { get; }
+ Task ChangeServer();
+ Task CopyLink();
+ Task GetSessionID();
+ Task Init();
+ Task PromptForHostName();
+ Task RemoveViewers();
+}
+
+public class MainViewViewModel : BrandedViewModelBase, IMainViewViewModel
+{
+ private readonly IAppState _appState;
+ private readonly IDesktopEnvironment _environment;
+ private readonly IDialogProvider _dialogProvider;
+ private readonly IDesktopHubConnection _hubConnection;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IViewModelFactory _viewModelFactory;
+ private IList _selectedViewers = new List();
+
+ public MainViewViewModel(
+ IBrandingProvider brandingProvider,
+ IUiDispatcher dispatcher,
+ IAppState appState,
+ IDesktopHubConnection hubConnection,
+ IServiceProvider serviceProvider,
+ IViewModelFactory viewModelFactory,
+ IDesktopEnvironment environmentHelper,
+ IDialogProvider dialogProvider,
+ ILogger