commit eec4f52b5acae8dd299ad2270203f476addcb60b Author: Patrick Gniza Date: Sun Aug 9 13:27:53 2026 +0200 Initialversion diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fc0729 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.vs/ +**/bin/ +**/obj/ +artifacts/ +dist/ +*.user +*.suo diff --git a/Configurator/Configurator.csproj b/Configurator/Configurator.csproj new file mode 100644 index 0000000..a2c2273 --- /dev/null +++ b/Configurator/Configurator.csproj @@ -0,0 +1,27 @@ + + + WinExe + net8.0-windows + true + enable + enable + PlandentMstscScriptHook + Plandent.MstscScriptHook.Configurator + x64 + x64 + win-x64 + true + true + false + embedded + $(MSBuildProjectDirectory)\..\artifacts\x64\$(Configuration)\PlandentMstscScriptHook.dll + + + + + + + + + + diff --git a/Configurator/HookConfig.cs b/Configurator/HookConfig.cs new file mode 100644 index 0000000..0f347ab --- /dev/null +++ b/Configurator/HookConfig.cs @@ -0,0 +1,14 @@ +namespace Plandent.MstscScriptHook.Configurator; + +internal sealed class HookConfig +{ + public bool Enabled { get; set; } = true; + public string ScriptPath { get; set; } = string.Empty; + public string Arguments { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; + public bool StartOnConnect { get; set; } = true; + public bool StopOnDisconnect { get; set; } + public bool Hidden { get; set; } = true; + public bool PreventDuplicates { get; set; } = true; + public bool EnableLogging { get; set; } = true; +} diff --git a/Configurator/MainForm.cs b/Configurator/MainForm.cs new file mode 100644 index 0000000..aea4a2d --- /dev/null +++ b/Configurator/MainForm.cs @@ -0,0 +1,468 @@ +using System.Drawing; +using System.Windows.Forms; +using System.Diagnostics; + +namespace Plandent.MstscScriptHook.Configurator; + +internal sealed class MainForm : Form +{ + private readonly TextBox scriptPathTextBox = new() { Dock = DockStyle.Fill }; + private readonly TextBox argumentsTextBox = new() { Dock = DockStyle.Fill }; + private readonly TextBox workingDirectoryTextBox = new() { Dock = DockStyle.Fill }; + + private readonly CheckBox enabledCheckBox = new() { Text = "Plugin-Funktion aktiviert", AutoSize = true }; + private readonly CheckBox startOnConnectCheckBox = new() { Text = "Script bei erfolgreicher RDP-Verbindung starten", AutoSize = true }; + private readonly CheckBox stopOnDisconnectCheckBox = new() { Text = "Script bei RDP-Trennung beenden", AutoSize = true }; + private readonly CheckBox hiddenCheckBox = new() { Text = "Script unsichtbar starten", AutoSize = true }; + private readonly CheckBox preventDuplicatesCheckBox = new() { Text = "Mehrfachstart verhindern", AutoSize = true }; + private readonly CheckBox loggingCheckBox = new() { Text = "Plugin-Logging aktivieren", AutoSize = true }; + + private readonly Label statusLabel = new() + { + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Padding = new Padding(8) + }; + + private readonly Button stopTestButton = new() { Text = "Test beenden", AutoSize = true, Enabled = false }; + private Process? testProcess; + + public MainForm() + { + Text = "Plandent MSTSC Script Hook"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(760, 590); + Size = new Size(820, 640); + Font = new Font("Segoe UI", 9F); + + Controls.Add(BuildMainLayout()); + Load += (_, _) => LoadConfiguration(); + FormClosed += (_, _) => DisposeTestProcessHandle(); + } + + private Control BuildMainLayout() + { + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(18), + ColumnCount = 1, + RowCount = 6 + }; + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var title = new Label + { + Text = "MSTSC Script Hook", + Font = new Font(Font.FontFamily, 16F, FontStyle.Bold), + AutoSize = true, + Margin = new Padding(0, 0, 0, 4) + }; + root.Controls.Add(title, 0, 0); + + var description = new Label + { + Text = "Startet bei einer erfolgreichen klassischen Remotedesktop-Verbindung lokal ein konfiguriertes Script im Hintergrund.", + AutoSize = true, + MaximumSize = new Size(740, 0), + Margin = new Padding(0, 0, 0, 16) + }; + root.Controls.Add(description, 0, 1); + + root.Controls.Add(BuildFileGrid(), 0, 2); + root.Controls.Add(BuildOptionsGroup(), 0, 3); + root.Controls.Add(BuildStatusPanel(), 0, 4); + root.Controls.Add(BuildButtons(), 0, 5); + return root; + } + + private Control BuildFileGrid() + { + var grid = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 3, + RowCount = 3, + Margin = new Padding(0, 0, 0, 14) + }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + + grid.Controls.Add(CreateFieldLabel("Script:"), 0, 0); + grid.Controls.Add(scriptPathTextBox, 1, 0); + var browseScriptButton = new Button { Text = "…", Width = 42, Height = scriptPathTextBox.PreferredHeight + 4 }; + browseScriptButton.Click += (_, _) => BrowseScript(); + grid.Controls.Add(browseScriptButton, 2, 0); + + grid.Controls.Add(CreateFieldLabel("Argumente:"), 0, 1); + grid.Controls.Add(argumentsTextBox, 1, 1); + grid.SetColumnSpan(argumentsTextBox, 2); + + grid.Controls.Add(CreateFieldLabel("Arbeitsverzeichnis:"), 0, 2); + grid.Controls.Add(workingDirectoryTextBox, 1, 2); + var browseWorkingButton = new Button { Text = "…", Width = 42, Height = workingDirectoryTextBox.PreferredHeight + 4 }; + browseWorkingButton.Click += (_, _) => BrowseWorkingDirectory(); + grid.Controls.Add(browseWorkingButton, 2, 2); + + return grid; + } + + private static Label CreateFieldLabel(string text) + { + return new Label + { + Text = text, + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(0, 6, 12, 6) + }; + } + + private Control BuildOptionsGroup() + { + var group = new GroupBox + { + Text = "Verhalten", + Dock = DockStyle.Top, + AutoSize = true, + Padding = new Padding(12), + Margin = new Padding(0, 0, 0, 14) + }; + + var options = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoSize = true + }; + options.Controls.Add(enabledCheckBox); + options.Controls.Add(startOnConnectCheckBox); + options.Controls.Add(stopOnDisconnectCheckBox); + options.Controls.Add(hiddenCheckBox); + options.Controls.Add(preventDuplicatesCheckBox); + options.Controls.Add(loggingCheckBox); + group.Controls.Add(options); + return group; + } + + private Control BuildStatusPanel() + { + var panel = new Panel + { + Dock = DockStyle.Top, + Height = 58, + BorderStyle = BorderStyle.FixedSingle, + Margin = new Padding(0, 0, 0, 14) + }; + panel.Controls.Add(statusLabel); + return panel; + } + + private Control BuildButtons() + { + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + Margin = new Padding(0) + }; + + var testButton = new Button { Text = "Script testen", AutoSize = true }; + testButton.Click += (_, _) => StartTest(); + stopTestButton.Click += (_, _) => StopTest(); + + var saveButton = new Button { Text = "Konfiguration speichern", AutoSize = true }; + saveButton.Click += (_, _) => SaveConfiguration(showConfirmation: true); + + var installButton = new Button { Text = "Plugin installieren", AutoSize = true }; + installButton.Click += (_, _) => InstallPlugin(); + + var removeButton = new Button { Text = "Plugin entfernen", AutoSize = true }; + removeButton.Click += (_, _) => RemovePlugin(); + + var logButton = new Button { Text = "Log öffnen", AutoSize = true }; + logButton.Click += (_, _) => OpenLog(); + + buttons.Controls.Add(testButton); + buttons.Controls.Add(stopTestButton); + buttons.Controls.Add(saveButton); + buttons.Controls.Add(installButton); + buttons.Controls.Add(removeButton); + buttons.Controls.Add(logButton); + return buttons; + } + + private void BrowseScript() + { + using var dialog = new OpenFileDialog + { + Title = "Script oder Programm auswählen", + Filter = "Unterstützte Dateien (*.bat;*.cmd;*.ps1;*.exe)|*.bat;*.cmd;*.ps1;*.exe|Alle Dateien (*.*)|*.*", + CheckFileExists = true + }; + + if (!string.IsNullOrWhiteSpace(scriptPathTextBox.Text)) + { + string expanded = Environment.ExpandEnvironmentVariables(scriptPathTextBox.Text); + if (File.Exists(expanded)) + { + dialog.InitialDirectory = Path.GetDirectoryName(expanded); + dialog.FileName = Path.GetFileName(expanded); + } + } + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + scriptPathTextBox.Text = dialog.FileName; + if (string.IsNullOrWhiteSpace(workingDirectoryTextBox.Text)) + { + workingDirectoryTextBox.Text = Path.GetDirectoryName(dialog.FileName) ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(argumentsTextBox.Text) && + Path.GetFileName(dialog.FileName).Contains("RemoteVDDS_Receiver_TS", StringComparison.OrdinalIgnoreCase)) + { + argumentsTextBox.Text = "MIN"; + } + } + + private void BrowseWorkingDirectory() + { + using var dialog = new FolderBrowserDialog + { + Description = "Arbeitsverzeichnis auswählen", + UseDescriptionForTitle = true, + ShowNewFolderButton = true + }; + + string expanded = Environment.ExpandEnvironmentVariables(workingDirectoryTextBox.Text); + if (Directory.Exists(expanded)) + { + dialog.SelectedPath = expanded; + } + + if (dialog.ShowDialog(this) == DialogResult.OK) + { + workingDirectoryTextBox.Text = dialog.SelectedPath; + } + } + + private HookConfig ReadForm() + { + return new HookConfig + { + Enabled = enabledCheckBox.Checked, + ScriptPath = scriptPathTextBox.Text.Trim(), + Arguments = argumentsTextBox.Text.Trim(), + WorkingDirectory = workingDirectoryTextBox.Text.Trim(), + StartOnConnect = startOnConnectCheckBox.Checked, + StopOnDisconnect = stopOnDisconnectCheckBox.Checked, + Hidden = hiddenCheckBox.Checked, + PreventDuplicates = preventDuplicatesCheckBox.Checked, + EnableLogging = loggingCheckBox.Checked + }; + } + + private void LoadConfiguration() + { + HookConfig config = RegistryConfig.Load(); + scriptPathTextBox.Text = config.ScriptPath; + argumentsTextBox.Text = config.Arguments; + workingDirectoryTextBox.Text = config.WorkingDirectory; + enabledCheckBox.Checked = config.Enabled; + startOnConnectCheckBox.Checked = config.StartOnConnect; + stopOnDisconnectCheckBox.Checked = config.StopOnDisconnect; + hiddenCheckBox.Checked = config.Hidden; + preventDuplicatesCheckBox.Checked = config.PreventDuplicates; + loggingCheckBox.Checked = config.EnableLogging; + RefreshStatus(); + } + + private bool SaveConfiguration(bool showConfirmation) + { + try + { + HookConfig config = ReadForm(); + ValidateConfig(config, requireScript: false); + RegistryConfig.Save(config); + if (showConfirmation) + { + MessageBox.Show(this, "Konfiguration wurde gespeichert.", "MSTSC Script Hook", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + RefreshStatus(); + return true; + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Konfiguration konnte nicht gespeichert werden", MessageBoxButtons.OK, MessageBoxIcon.Error); + return false; + } + } + + private void InstallPlugin() + { + try + { + HookConfig config = ReadForm(); + ValidateConfig(config, requireScript: true); + RegistryConfig.Save(config); + PluginInstaller.Install(); + RefreshStatus(); + MessageBox.Show( + this, + "Plugin wurde für den aktuellen Benutzer installiert und die Konfiguration gespeichert.\n\n" + + "Bereits geöffnete mstsc.exe-Fenster vollständig schließen und anschließend neu starten, damit die DLL geladen wird.", + "Installation abgeschlossen", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Installation fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void RemovePlugin() + { + UninstallResult result = PluginInstaller.Uninstall(); + RefreshStatus(); + MessageBox.Show( + this, + result.Message, + "Plugin entfernen", + MessageBoxButtons.OK, + result.FullyRemoved ? MessageBoxIcon.Information : MessageBoxIcon.Warning); + } + + private void StartTest() + { + try + { + StopTest(silent: true); + HookConfig config = ReadForm(); + ValidateConfig(config, requireScript: true); + testProcess = ScriptTestLauncher.Start(config); + stopTestButton.Enabled = true; + statusLabel.Text = $"Testprozess gestartet: PID {testProcess.Id}. Der Test läuft unabhängig von MSTSC."; + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Script-Test fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void StopTest() => StopTest(silent: false); + + private void StopTest(bool silent) + { + if (testProcess is null) + { + stopTestButton.Enabled = false; + return; + } + + try + { + if (!testProcess.HasExited) + { + testProcess.Kill(entireProcessTree: true); + testProcess.WaitForExit(3000); + } + if (!silent) + { + statusLabel.Text = "Testprozess wurde beendet."; + } + } + catch (Exception ex) + { + if (!silent) + { + MessageBox.Show(this, ex.Message, "Testprozess konnte nicht beendet werden", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + finally + { + DisposeTestProcessHandle(); + } + } + + private void DisposeTestProcessHandle() + { + testProcess?.Dispose(); + testProcess = null; + stopTestButton.Enabled = false; + } + + private void OpenLog() + { + string logPath = Path.Combine(PluginInstaller.InstallDirectory, "plugin.log"); + if (!File.Exists(logPath)) + { + MessageBox.Show(this, "Es existiert noch kein Plugin-Log.", "MSTSC Script Hook", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + Process.Start(new ProcessStartInfo + { + FileName = logPath, + UseShellExecute = true + }); + } + + private void RefreshStatus() + { + bool installed = PluginInstaller.IsInstalled(out string pluginStatus); + string configStatus = string.IsNullOrWhiteSpace(scriptPathTextBox.Text) + ? " Kein Script konfiguriert." + : $" Script: {scriptPathTextBox.Text}"; + statusLabel.Text = (installed ? "✓ " : "○ ") + pluginStatus + configStatus; + } + + private static void ValidateConfig(HookConfig config, bool requireScript) + { + if (!requireScript && string.IsNullOrWhiteSpace(config.ScriptPath)) + { + return; + } + + string scriptPath = Environment.ExpandEnvironmentVariables(config.ScriptPath); + if (string.IsNullOrWhiteSpace(scriptPath)) + { + throw new InvalidOperationException("Bitte ein Script auswählen."); + } + + if (!File.Exists(scriptPath)) + { + throw new FileNotFoundException("Das ausgewählte Script wurde nicht gefunden.", scriptPath); + } + + string extension = Path.GetExtension(scriptPath).ToLowerInvariant(); + if (extension is not (".bat" or ".cmd" or ".ps1" or ".exe")) + { + throw new NotSupportedException("Unterstützt werden .bat, .cmd, .ps1 und .exe."); + } + + if (!string.IsNullOrWhiteSpace(config.WorkingDirectory)) + { + string workDir = Environment.ExpandEnvironmentVariables(config.WorkingDirectory); + if (!Directory.Exists(workDir)) + { + throw new DirectoryNotFoundException("Das Arbeitsverzeichnis wurde nicht gefunden: " + workDir); + } + } + } +} diff --git a/Configurator/PluginInstaller.cs b/Configurator/PluginInstaller.cs new file mode 100644 index 0000000..f2d040d --- /dev/null +++ b/Configurator/PluginInstaller.cs @@ -0,0 +1,113 @@ +using System.Reflection; + +namespace Plandent.MstscScriptHook.Configurator; + +internal static class PluginInstaller +{ + private const string ResourceName = "PlandentMstscScriptHook.Native.PlandentMstscScriptHook.dll"; + public const string DllFileName = "PlandentMstscScriptHook.dll"; + + public static string InstallDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Plandent", + "MstscScriptHook"); + + public static string InstalledDllPath => Path.Combine(InstallDirectory, DllFileName); + + public static void Install() + { + Directory.CreateDirectory(InstallDirectory); + ExtractPluginBinary(InstalledDllPath); + RegistryConfig.RegisterPlugin(InstalledDllPath); + } + + public static UninstallResult Uninstall() + { + RegistryConfig.UnregisterPlugin(); + + if (!File.Exists(InstalledDllPath)) + { + return new UninstallResult(true, "Plugin-Registrierung entfernt."); + } + + try + { + File.Delete(InstalledDllPath); + TryDeleteEmptyInstallDirectory(); + return new UninstallResult(true, "Plugin-Registrierung und DLL wurden entfernt."); + } + catch (IOException) + { + return new UninstallResult( + false, + "Die Registrierung wurde entfernt. Die DLL wird noch von mstsc.exe verwendet. " + + "Alle Remote-Desktop-Clients schließen und die Datei anschließend löschen bzw. erneut auf „Plugin entfernen“ klicken."); + } + catch (UnauthorizedAccessException ex) + { + return new UninstallResult(false, "Registrierung entfernt, DLL konnte aber nicht gelöscht werden: " + ex.Message); + } + } + + public static bool IsInstalled(out string status) + { + string? registered = RegistryConfig.GetRegisteredPluginPath(); + if (string.IsNullOrWhiteSpace(registered)) + { + status = "Plugin ist nicht registriert."; + return false; + } + + string expanded = Environment.ExpandEnvironmentVariables(registered); + if (!File.Exists(expanded)) + { + status = $"Plugin ist registriert, DLL fehlt jedoch: {expanded}"; + return false; + } + + status = $"Plugin ist registriert: {expanded}"; + return true; + } + + private static void ExtractPluginBinary(string destination) + { + Assembly assembly = Assembly.GetExecutingAssembly(); + using Stream? resource = assembly.GetManifestResourceStream(ResourceName); + if (resource is not null) + { + using FileStream output = new(destination, FileMode.Create, FileAccess.Write, FileShare.None); + resource.CopyTo(output); + return; + } + + // Praktischer Fallback für Debugging/Entwicklung: DLL direkt neben der EXE akzeptieren. + string adjacent = Path.Combine(AppContext.BaseDirectory, DllFileName); + if (File.Exists(adjacent)) + { + File.Copy(adjacent, destination, overwrite: true); + return; + } + + throw new FileNotFoundException( + "Die native Plugin-DLL ist weder als Resource eingebettet noch neben der Konfigurations-EXE vorhanden. " + + "Bitte zuerst build-release.ps1 ausführen.", + DllFileName); + } + + private static void TryDeleteEmptyInstallDirectory() + { + try + { + if (Directory.Exists(InstallDirectory) && !Directory.EnumerateFileSystemEntries(InstallDirectory).Any()) + { + Directory.Delete(InstallDirectory); + } + } + catch + { + // Unkritisch: ein leeres Verzeichnis darf bestehen bleiben. + } + } +} + +internal sealed record UninstallResult(bool FullyRemoved, string Message); diff --git a/Configurator/Program.cs b/Configurator/Program.cs new file mode 100644 index 0000000..0c24be8 --- /dev/null +++ b/Configurator/Program.cs @@ -0,0 +1,13 @@ +using System.Windows.Forms; + +namespace Plandent.MstscScriptHook.Configurator; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} diff --git a/Configurator/RegistryConfig.cs b/Configurator/RegistryConfig.cs new file mode 100644 index 0000000..9acfd0c --- /dev/null +++ b/Configurator/RegistryConfig.cs @@ -0,0 +1,81 @@ +using Microsoft.Win32; + +namespace Plandent.MstscScriptHook.Configurator; + +internal static class RegistryConfig +{ + public const string ConfigKeyPath = @"Software\Plandent\MstscScriptHook"; + public const string AddInKeyPath = @"Software\Microsoft\Terminal Server Client\Default\AddIns\PlandentMstscScriptHook"; + + public static HookConfig Load() + { + var config = new HookConfig(); + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(ConfigKeyPath, writable: false); + if (key is null) + { + return config; + } + + config.Enabled = ReadBool(key, "Enabled", true); + config.ScriptPath = ReadString(key, "ScriptPath"); + config.Arguments = ReadString(key, "Arguments"); + config.WorkingDirectory = ReadString(key, "WorkingDirectory"); + config.StartOnConnect = ReadBool(key, "StartOnConnect", true); + config.StopOnDisconnect = ReadBool(key, "StopOnDisconnect", false); + config.Hidden = ReadBool(key, "Hidden", true); + config.PreventDuplicates = ReadBool(key, "PreventDuplicates", true); + config.EnableLogging = ReadBool(key, "EnableLogging", true); + return config; + } + + public static void Save(HookConfig config) + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(ConfigKeyPath, writable: true) + ?? throw new InvalidOperationException("Registry-Schlüssel konnte nicht erstellt werden."); + + key.SetValue("Enabled", config.Enabled ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("ScriptPath", config.ScriptPath ?? string.Empty, RegistryValueKind.ExpandString); + key.SetValue("Arguments", config.Arguments ?? string.Empty, RegistryValueKind.String); + key.SetValue("WorkingDirectory", config.WorkingDirectory ?? string.Empty, RegistryValueKind.ExpandString); + key.SetValue("StartOnConnect", config.StartOnConnect ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("StopOnDisconnect", config.StopOnDisconnect ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("Hidden", config.Hidden ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("PreventDuplicates", config.PreventDuplicates ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("EnableLogging", config.EnableLogging ? 1 : 0, RegistryValueKind.DWord); + } + + public static string? GetRegisteredPluginPath() + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(AddInKeyPath, writable: false); + return key?.GetValue("Name", null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + } + + public static void RegisterPlugin(string dllPath) + { + using RegistryKey key = Registry.CurrentUser.CreateSubKey(AddInKeyPath, writable: true) + ?? throw new InvalidOperationException("MSTSC AddIn-Registry-Schlüssel konnte nicht erstellt werden."); + key.SetValue("Name", dllPath, RegistryValueKind.String); + } + + public static void UnregisterPlugin() + { + Registry.CurrentUser.DeleteSubKeyTree(AddInKeyPath, throwOnMissingSubKey: false); + } + + private static bool ReadBool(RegistryKey key, string name, bool defaultValue) + { + object? value = key.GetValue(name); + return value switch + { + int i => i != 0, + long l => l != 0, + _ => defaultValue + }; + } + + private static string ReadString(RegistryKey key, string name, string defaultValue = "") + { + return key.GetValue(name, defaultValue, RegistryValueOptions.DoNotExpandEnvironmentNames) as string + ?? defaultValue; + } +} diff --git a/Configurator/ScriptTestLauncher.cs b/Configurator/ScriptTestLauncher.cs new file mode 100644 index 0000000..f65d7c1 --- /dev/null +++ b/Configurator/ScriptTestLauncher.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; + +namespace Plandent.MstscScriptHook.Configurator; + +internal static class ScriptTestLauncher +{ + public static Process Start(HookConfig config) + { + string scriptPath = Environment.ExpandEnvironmentVariables(config.ScriptPath ?? string.Empty); + if (string.IsNullOrWhiteSpace(scriptPath) || !File.Exists(scriptPath)) + { + throw new FileNotFoundException("Das ausgewählte Script wurde nicht gefunden.", scriptPath); + } + + string extension = Path.GetExtension(scriptPath).ToLowerInvariant(); + string workingDirectory = string.IsNullOrWhiteSpace(config.WorkingDirectory) + ? Path.GetDirectoryName(scriptPath) ?? Environment.CurrentDirectory + : Environment.ExpandEnvironmentVariables(config.WorkingDirectory); + + var startInfo = new ProcessStartInfo + { + UseShellExecute = false, + CreateNoWindow = config.Hidden, + WindowStyle = config.Hidden ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal, + WorkingDirectory = workingDirectory + }; + + switch (extension) + { + case ".bat": + case ".cmd": + startInfo.FileName = Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + startInfo.ArgumentList.Add("/D"); + startInfo.ArgumentList.Add("/S"); + startInfo.ArgumentList.Add("/C"); + startInfo.ArgumentList.Add(BuildCmdCommand(scriptPath, config.Arguments)); + break; + + case ".ps1": + startInfo.FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(scriptPath); + AddRawArguments(startInfo, config.Arguments); + break; + + case ".exe": + startInfo.FileName = scriptPath; + AddRawArguments(startInfo, config.Arguments); + break; + + default: + throw new NotSupportedException("Unterstützt werden .bat, .cmd, .ps1 und .exe."); + } + + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Der Testprozess konnte nicht gestartet werden."); + } + + private static string BuildCmdCommand(string scriptPath, string? arguments) + { + string command = QuoteForCmd(scriptPath); + if (!string.IsNullOrWhiteSpace(arguments)) + { + command += " " + arguments; + } + return command; + } + + private static string QuoteForCmd(string value) + { + return "\"" + value.Replace("\"", "\\\"") + "\""; + } + + // Die GUI behandelt das Argumentfeld bewusst als Kommandozeilen-Argumentstring. + // Für den Test wird eine einfache Windows-Argumentzerlegung verwendet. Das Plugin selbst + // übergibt den String unverändert an cmd.exe/PowerShell bzw. an die EXE-Kommandozeile. + private static void AddRawArguments(ProcessStartInfo startInfo, string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + foreach (string argument in CommandLineSplitter.Split(raw)) + { + startInfo.ArgumentList.Add(argument); + } + } +} + +internal static class CommandLineSplitter +{ + public static IReadOnlyList Split(string commandLine) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + int backslashes = 0; + + for (int i = 0; i < commandLine.Length; i++) + { + char ch = commandLine[i]; + + if (ch == '\\') + { + backslashes++; + continue; + } + + if (ch == '"') + { + current.Append('\\', backslashes / 2); + if (backslashes % 2 == 0) + { + inQuotes = !inQuotes; + } + else + { + current.Append('"'); + } + backslashes = 0; + continue; + } + + current.Append('\\', backslashes); + backslashes = 0; + + if (char.IsWhiteSpace(ch) && !inQuotes) + { + if (current.Length > 0) + { + result.Add(current.ToString()); + current.Clear(); + } + } + else + { + current.Append(ch); + } + } + + current.Append('\\', backslashes); + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } +} diff --git a/PlandentMstscScriptHook.sln b/PlandentMstscScriptHook.sln new file mode 100644 index 0000000..d018b54 --- /dev/null +++ b/PlandentMstscScriptHook.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Plugin", "Plugin\PlandentMstscScriptHook.vcxproj", "{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlandentMstscScriptHook.Configurator", "Configurator\Configurator.csproj", "{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}" + ProjectSection(ProjectDependencies) = postProject + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} = {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Debug|x64.ActiveCfg = Debug|x64 + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Debug|x64.Build.0 = Debug|x64 + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Release|x64.ActiveCfg = Release|x64 + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Release|x64.Build.0 = Release|x64 + {935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Debug|x64.ActiveCfg = Debug|x64 + {935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Debug|x64.Build.0 = Debug|x64 + {935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.ActiveCfg = Release|x64 + {935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Plugin/DllMain.cpp b/Plugin/DllMain.cpp new file mode 100644 index 0000000..6f921a5 --- /dev/null +++ b/Plugin/DllMain.cpp @@ -0,0 +1,57 @@ +#include "ScriptHookPlugin.h" + +#include +#include +#include +#include + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) +{ + UNREFERENCED_PARAMETER(reserved); + if (reason == DLL_PROCESS_ATTACH) + { + DisableThreadLibraryCalls(module); + } + return TRUE; +} + +// Der Prototyp ist laut Microsoft nicht in einem öffentlichen Header enthalten +// und muss exakt in dieser Form exportiert werden. +extern "C" __declspec(dllexport) HRESULT VCAPITYPE VirtualChannelGetInstance( + _In_ REFIID refiid, + _Inout_ ULONG* pNumObjs, + _Out_opt_ VOID** ppObjArray) +{ + if (pNumObjs == nullptr) + { + return E_POINTER; + } + + if (refiid != __uuidof(IWTSPlugin)) + { + return E_NOINTERFACE; + } + + constexpr ULONG requiredObjects = 1; + if (ppObjArray == nullptr) + { + *pNumObjs = requiredObjects; + return S_OK; + } + + if (*pNumObjs < requiredObjects) + { + *pNumObjs = requiredObjects; + return HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER); + } + + auto* plugin = new (std::nothrow) ScriptHookPlugin(); + if (plugin == nullptr) + { + return E_OUTOFMEMORY; + } + + ppObjArray[0] = static_cast(plugin); + *pNumObjs = requiredObjects; + return S_OK; +} diff --git a/Plugin/Logging.cpp b/Plugin/Logging.cpp new file mode 100644 index 0000000..693009d --- /dev/null +++ b/Plugin/Logging.cpp @@ -0,0 +1,114 @@ +#include "Logging.h" + +#include +#include +#include +#include +#include + +namespace +{ + std::wstring GetLogDirectory() + { + wchar_t localAppData[32768]{}; + DWORD len = GetEnvironmentVariableW(L"LOCALAPPDATA", localAppData, static_cast(std::size(localAppData))); + if (len == 0 || len >= std::size(localAppData)) + { + return {}; + } + + std::wstring base(localAppData); + std::wstring plandent = base + L"\\Plandent"; + std::wstring hookDir = plandent + L"\\MstscScriptHook"; + CreateDirectoryW(plandent.c_str(), nullptr); + CreateDirectoryW(hookDir.c_str(), nullptr); + return hookDir; + } + + std::string ToUtf8(const std::wstring& value) + { + if (value.empty()) + { + return {}; + } + int needed = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (needed <= 0) + { + return {}; + } + std::string result(static_cast(needed), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), needed, nullptr, nullptr); + return result; + } +} + +namespace hook +{ + std::wstring FormatWin32Error(unsigned long errorCode) + { + wchar_t* buffer = nullptr; + DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS; + DWORD length = FormatMessageW(flags, nullptr, errorCode, 0, reinterpret_cast(&buffer), 0, nullptr); + if (length == 0 || buffer == nullptr) + { + return L"Win32-Fehler " + std::to_wstring(errorCode); + } + + std::wstring result(buffer, length); + LocalFree(buffer); + while (!result.empty() && (result.back() == L'\r' || result.back() == L'\n' || result.back() == L' ')) + { + result.pop_back(); + } + return result; + } + + void Log(bool enabled, const std::wstring& message) + { + if (!enabled) + { + return; + } + + std::wstring directory = GetLogDirectory(); + if (directory.empty()) + { + return; + } + + SYSTEMTIME st{}; + GetLocalTime(&st); + + wchar_t prefix[128]{}; + swprintf_s(prefix, std::size(prefix), L"%04u-%02u-%02u %02u:%02u:%02u.%03u [PID:%lu TID:%lu] ", + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, + GetCurrentProcessId(), GetCurrentThreadId()); + + std::wstring fullLine = std::wstring(prefix) + message + L"\r\n"; + std::string utf8 = ToUtf8(fullLine); + if (utf8.empty()) + { + return; + } + + std::wstring logPath = directory + L"\\plugin.log"; + HANDLE file = CreateFileW( + logPath.c_str(), + FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + return; + } + + DWORD written = 0; + WriteFile(file, utf8.data(), static_cast(utf8.size()), &written, nullptr); + CloseHandle(file); + } +} diff --git a/Plugin/Logging.h b/Plugin/Logging.h new file mode 100644 index 0000000..2b45408 --- /dev/null +++ b/Plugin/Logging.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace hook +{ + void Log(bool enabled, const std::wstring& message); + std::wstring FormatWin32Error(unsigned long errorCode); +} diff --git a/Plugin/PlandentMstscScriptHook.vcxproj b/Plugin/PlandentMstscScriptHook.vcxproj new file mode 100644 index 0000000..89850ec --- /dev/null +++ b/Plugin/PlandentMstscScriptHook.vcxproj @@ -0,0 +1,103 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} + PlandentMstscScriptHook + 10.0 + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + + $(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\ + $(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\Plugin\ + PlandentMstscScriptHook + + + + Level4 + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + MultiThreadedDebug + + + Windows + true + exports.def + Advapi32.lib;%(AdditionalDependencies) + + + + + Level4 + true + true + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + MultiThreaded + + + Windows + true + true + false + exports.def + Advapi32.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + diff --git a/Plugin/PlandentMstscScriptHook.vcxproj.filters b/Plugin/PlandentMstscScriptHook.vcxproj.filters new file mode 100644 index 0000000..037f4ff --- /dev/null +++ b/Plugin/PlandentMstscScriptHook.vcxproj.filters @@ -0,0 +1,20 @@ + + + + {F4A82F73-9E41-44E6-AB6B-69D29AC5C3CC}cpp;c + {D93CE101-7264-440E-A04E-D633530285A9}h;hpp + + + Source Files + Source Files + Source Files + Source Files + Source Files + + + Header Files + Header Files + Header Files + Header Files + + diff --git a/Plugin/ProcessLauncher.cpp b/Plugin/ProcessLauncher.cpp new file mode 100644 index 0000000..2fc9506 --- /dev/null +++ b/Plugin/ProcessLauncher.cpp @@ -0,0 +1,426 @@ +#include "ProcessLauncher.h" +#include "Logging.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + std::wstring QuoteWindowsArgument(const std::wstring& arg) + { + if (arg.empty()) + { + return L"\"\""; + } + + if (arg.find_first_of(L" \t\n\v\"") == std::wstring::npos) + { + return arg; + } + + std::wstring result = L"\""; + size_t backslashes = 0; + for (wchar_t ch : arg) + { + if (ch == L'\\') + { + ++backslashes; + continue; + } + + if (ch == L'\"') + { + result.append(backslashes * 2 + 1, L'\\'); + result.push_back(L'\"'); + backslashes = 0; + continue; + } + + result.append(backslashes, L'\\'); + backslashes = 0; + result.push_back(ch); + } + + result.append(backslashes * 2, L'\\'); + result.push_back(L'\"'); + return result; + } + + std::wstring GetExtensionLower(const std::wstring& path) + { + const size_t dot = path.find_last_of(L'.'); + const size_t slash = path.find_last_of(L"\\/"); + if (dot == std::wstring::npos || (slash != std::wstring::npos && dot < slash)) + { + return {}; + } + + std::wstring ext = path.substr(dot); + std::transform(ext.begin(), ext.end(), ext.begin(), [](wchar_t c) { return static_cast(std::towlower(c)); }); + return ext; + } + + std::wstring ParentDirectory(const std::wstring& path) + { + const size_t slash = path.find_last_of(L"\\/"); + if (slash == std::wstring::npos) + { + return {}; + } + if (slash == 2 && path.size() >= 3 && path[1] == L':') + { + return path.substr(0, 3); + } + return path.substr(0, slash); + } + + std::wstring GetEnvironmentValue(const wchar_t* name) + { + DWORD size = GetEnvironmentVariableW(name, nullptr, 0); + if (size == 0) + { + return {}; + } + std::vector buffer(size, L'\0'); + if (GetEnvironmentVariableW(name, buffer.data(), size) == 0) + { + return {}; + } + return std::wstring(buffer.data()); + } + + uint64_t HashPath(const std::wstring& path) + { + constexpr uint64_t fnvOffset = 14695981039346656037ull; + constexpr uint64_t fnvPrime = 1099511628211ull; + uint64_t hash = fnvOffset; + for (wchar_t ch : path) + { + wchar_t lower = static_cast(std::towlower(ch)); + hash ^= static_cast(lower); + hash *= fnvPrime; + } + return hash; + } + + std::wstring MutexNameForScript(const std::wstring& scriptPath) + { + std::wstringstream stream; + stream << L"Local\\Plandent.MstscScriptHook." + << std::uppercase << std::hex << std::setw(16) << std::setfill(L'0') + << HashPath(scriptPath); + return stream.str(); + } + + struct LaunchCommand + { + std::wstring application; + std::wstring commandLine; + }; + + bool BuildLaunchCommand(const hook::HookConfig& config, LaunchCommand& command) + { + const std::wstring extension = GetExtensionLower(config.scriptPath); + + if (extension == L".bat" || extension == L".cmd") + { + std::wstring comspec = GetEnvironmentValue(L"ComSpec"); + if (comspec.empty()) + { + std::wstring systemRoot = GetEnvironmentValue(L"SystemRoot"); + if (systemRoot.empty()) + { + return false; + } + comspec = systemRoot + L"\\System32\\cmd.exe"; + } + + std::wstring inner = QuoteWindowsArgument(config.scriptPath); + if (!config.arguments.empty()) + { + inner += L" " + config.arguments; + } + + command.application = comspec; + command.commandLine = QuoteWindowsArgument(comspec) + L" /D /S /C \"" + inner + L"\""; + return true; + } + + if (extension == L".ps1") + { + std::wstring systemRoot = GetEnvironmentValue(L"SystemRoot"); + if (systemRoot.empty()) + { + return false; + } + + std::wstring powershell = systemRoot + L"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + command.application = powershell; + command.commandLine = QuoteWindowsArgument(powershell) + + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + + QuoteWindowsArgument(config.scriptPath); + if (!config.arguments.empty()) + { + command.commandLine += L" " + config.arguments; + } + return true; + } + + if (extension == L".exe") + { + command.application = config.scriptPath; + command.commandLine = QuoteWindowsArgument(config.scriptPath); + if (!config.arguments.empty()) + { + command.commandLine += L" " + config.arguments; + } + return true; + } + + return false; + } +} + +namespace hook +{ + ProcessLauncher::~ProcessLauncher() + { + if (process_ != nullptr) + { + CloseHandle(process_); + process_ = nullptr; + } + } + + void ProcessLauncher::CleanupExitedProcess() + { + if (process_ == nullptr) + { + return; + } + + if (WaitForSingleObject(process_, 0) != WAIT_TIMEOUT) + { + CloseHandle(process_); + process_ = nullptr; + processId_ = 0; + } + } + + bool ProcessLauncher::IsRunning() + { + CleanupExitedProcess(); + return process_ != nullptr; + } + + bool ProcessLauncher::Start(const HookConfig& config) + { + CleanupExitedProcess(); + if (process_ != nullptr) + { + Log(config.enableLogging, L"Script ist für diese MSTSC-Instanz bereits aktiv (PID " + std::to_wstring(processId_) + L")."); + return true; + } + + if (config.scriptPath.empty()) + { + Log(config.enableLogging, L"Kein ScriptPath konfiguriert; Start übersprungen."); + return false; + } + + DWORD attributes = GetFileAttributesW(config.scriptPath.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) + { + Log(config.enableLogging, L"Script wurde nicht gefunden: " + config.scriptPath); + return false; + } + + LaunchCommand command; + if (!BuildLaunchCommand(config, command)) + { + Log(config.enableLogging, L"Nicht unterstützter Script-/Dateityp: " + config.scriptPath); + return false; + } + + std::wstring workingDirectory = config.workingDirectory.empty() + ? ParentDirectory(config.scriptPath) + : config.workingDirectory; + + HANDLE duplicateMutex = nullptr; + if (config.preventDuplicates) + { + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + std::wstring mutexName = MutexNameForScript(config.scriptPath); + duplicateMutex = CreateMutexW(&sa, FALSE, mutexName.c_str()); + if (duplicateMutex == nullptr) + { + DWORD error = GetLastError(); + Log(config.enableLogging, L"Mehrfachstart-Schutz konnte nicht erstellt werden: " + FormatWin32Error(error)); + return false; + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) + { + CloseHandle(duplicateMutex); + Log(config.enableLogging, L"Ein Receiver mit demselben ScriptPath läuft bereits; kein weiterer Start."); + return true; + } + } + + PROCESS_INFORMATION pi{}; + DWORD creationFlags = 0; + if (config.hidden) + { + creationFlags |= CREATE_NO_WINDOW; + } + + std::vector mutableCommand(command.commandLine.begin(), command.commandLine.end()); + mutableCommand.push_back(L'\0'); + + BOOL created = FALSE; + + if (duplicateMutex != nullptr) + { + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + if (config.hidden) + { + startup.StartupInfo.dwFlags |= STARTF_USESHOWWINDOW; + startup.StartupInfo.wShowWindow = SW_HIDE; + } + + SIZE_T attributeSize = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize); + std::vector attributeBuffer(attributeSize); + startup.lpAttributeList = reinterpret_cast(attributeBuffer.data()); + + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &attributeSize)) + { + DWORD error = GetLastError(); + CloseHandle(duplicateMutex); + Log(config.enableLogging, L"Prozessattributliste konnte nicht initialisiert werden: " + FormatWin32Error(error)); + return false; + } + + HANDLE handles[] = { duplicateMutex }; + if (!UpdateProcThreadAttribute( + startup.lpAttributeList, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles, + sizeof(handles), + nullptr, + nullptr)) + { + DWORD error = GetLastError(); + DeleteProcThreadAttributeList(startup.lpAttributeList); + CloseHandle(duplicateMutex); + Log(config.enableLogging, L"Handle-Vererbung konnte nicht vorbereitet werden: " + FormatWin32Error(error)); + return false; + } + + creationFlags |= EXTENDED_STARTUPINFO_PRESENT; + created = CreateProcessW( + command.application.c_str(), + mutableCommand.data(), + nullptr, + nullptr, + TRUE, + creationFlags, + nullptr, + workingDirectory.empty() ? nullptr : workingDirectory.c_str(), + &startup.StartupInfo, + &pi); + + DeleteProcThreadAttributeList(startup.lpAttributeList); + + // Der Child-Prozess hat bei erfolgreichem Start eine eigene Referenz auf den Mutex. + // Dadurch bleibt der Mehrfachstart-Schutz auch bestehen, wenn mstsc.exe später endet. + CloseHandle(duplicateMutex); + } + else + { + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + if (config.hidden) + { + startup.dwFlags |= STARTF_USESHOWWINDOW; + startup.wShowWindow = SW_HIDE; + } + + created = CreateProcessW( + command.application.c_str(), + mutableCommand.data(), + nullptr, + nullptr, + FALSE, + creationFlags, + nullptr, + workingDirectory.empty() ? nullptr : workingDirectory.c_str(), + &startup, + &pi); + } + + if (!created) + { + DWORD error = GetLastError(); + Log(config.enableLogging, L"Scriptstart fehlgeschlagen: " + FormatWin32Error(error)); + return false; + } + + CloseHandle(pi.hThread); + process_ = pi.hProcess; + processId_ = pi.dwProcessId; + + Log(config.enableLogging, + L"Script gestartet. PID=" + std::to_wstring(processId_) + + L", Script=" + config.scriptPath + + (config.arguments.empty() ? L"" : L", Argumente=" + config.arguments)); + return true; + } + + void ProcessLauncher::Stop(bool loggingEnabled) + { + CleanupExitedProcess(); + if (process_ == nullptr) + { + return; + } + + const DWORD pid = processId_; + if (!TerminateProcess(process_, 0)) + { + DWORD error = GetLastError(); + Log(loggingEnabled, L"Scriptprozess PID " + std::to_wstring(pid) + L" konnte nicht beendet werden: " + FormatWin32Error(error)); + } + else + { + Log(loggingEnabled, L"Beenden des Scriptprozesses PID " + std::to_wstring(pid) + L" wurde bei RDP-Trennung angefordert."); + } + + CloseHandle(process_); + process_ = nullptr; + processId_ = 0; + } + + void ProcessLauncher::Detach(bool loggingEnabled) + { + CleanupExitedProcess(); + if (process_ == nullptr) + { + return; + } + + Log(loggingEnabled, L"MSTSC beendet Plugin; Script PID " + std::to_wstring(processId_) + L" läuft weiter."); + CloseHandle(process_); + process_ = nullptr; + processId_ = 0; + } +} diff --git a/Plugin/ProcessLauncher.h b/Plugin/ProcessLauncher.h new file mode 100644 index 0000000..7755be5 --- /dev/null +++ b/Plugin/ProcessLauncher.h @@ -0,0 +1,29 @@ +#pragma once + +#include "RegistryConfig.h" +#include +#include + +namespace hook +{ + class ProcessLauncher + { + public: + ProcessLauncher() = default; + ~ProcessLauncher(); + + ProcessLauncher(const ProcessLauncher&) = delete; + ProcessLauncher& operator=(const ProcessLauncher&) = delete; + + bool Start(const HookConfig& config); + void Stop(bool loggingEnabled); + void Detach(bool loggingEnabled); + bool IsRunning(); + + private: + HANDLE process_ = nullptr; + DWORD processId_ = 0; + + void CleanupExitedProcess(); + }; +} diff --git a/Plugin/RegistryConfig.cpp b/Plugin/RegistryConfig.cpp new file mode 100644 index 0000000..23aa062 --- /dev/null +++ b/Plugin/RegistryConfig.cpp @@ -0,0 +1,91 @@ +#include "RegistryConfig.h" + +#include + +namespace +{ + DWORD ReadDword(HKEY key, const wchar_t* name, DWORD defaultValue) + { + DWORD value = defaultValue; + DWORD size = sizeof(value); + DWORD type = 0; + if (RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(&value), &size) != ERROR_SUCCESS || + type != REG_DWORD) + { + return defaultValue; + } + return value; + } + + std::wstring ReadString(HKEY key, const wchar_t* name) + { + DWORD type = 0; + DWORD size = 0; + if (RegQueryValueExW(key, name, nullptr, &type, nullptr, &size) != ERROR_SUCCESS || + (type != REG_SZ && type != REG_EXPAND_SZ) || size == 0) + { + return {}; + } + + std::vector buffer((size / sizeof(wchar_t)) + 1, L'\0'); + if (RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(buffer.data()), &size) != ERROR_SUCCESS) + { + return {}; + } + + std::wstring value(buffer.data()); + if (type == REG_EXPAND_SZ) + { + return hook::ExpandEnvironment(value); + } + return value; + } +} + +namespace hook +{ + std::wstring ExpandEnvironment(const std::wstring& value) + { + if (value.empty()) + { + return value; + } + + DWORD needed = ExpandEnvironmentStringsW(value.c_str(), nullptr, 0); + if (needed == 0) + { + return value; + } + + std::vector buffer(needed, L'\0'); + if (ExpandEnvironmentStringsW(value.c_str(), buffer.data(), needed) == 0) + { + return value; + } + return std::wstring(buffer.data()); + } + + HookConfig LoadConfig() + { + HookConfig config; + + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kConfigKey, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) + { + return config; + } + + config.enabled = ReadDword(key, L"Enabled", 1) != 0; + config.scriptPath = ReadString(key, L"ScriptPath"); + config.arguments = ExpandEnvironment(ReadString(key, L"Arguments")); + config.workingDirectory = ReadString(key, L"WorkingDirectory"); + config.startOnConnect = ReadDword(key, L"StartOnConnect", 1) != 0; + config.stopOnDisconnect = ReadDword(key, L"StopOnDisconnect", 0) != 0; + config.hidden = ReadDword(key, L"Hidden", 1) != 0; + config.preventDuplicates = ReadDword(key, L"PreventDuplicates", 1) != 0; + config.enableLogging = ReadDword(key, L"EnableLogging", 1) != 0; + + RegCloseKey(key); + return config; + } +} diff --git a/Plugin/RegistryConfig.h b/Plugin/RegistryConfig.h new file mode 100644 index 0000000..78e152b --- /dev/null +++ b/Plugin/RegistryConfig.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace hook +{ + inline constexpr wchar_t kConfigKey[] = L"Software\\Plandent\\MstscScriptHook"; + + struct HookConfig + { + bool enabled = true; + std::wstring scriptPath; + std::wstring arguments; + std::wstring workingDirectory; + bool startOnConnect = true; + bool stopOnDisconnect = false; + bool hidden = true; + bool preventDuplicates = true; + bool enableLogging = true; + }; + + HookConfig LoadConfig(); + std::wstring ExpandEnvironment(const std::wstring& value); +} diff --git a/Plugin/ScriptHookPlugin.cpp b/Plugin/ScriptHookPlugin.cpp new file mode 100644 index 0000000..eaa5f7d --- /dev/null +++ b/Plugin/ScriptHookPlugin.cpp @@ -0,0 +1,93 @@ +#include "ScriptHookPlugin.h" + +#include "Logging.h" +#include "RegistryConfig.h" + +ScriptHookPlugin::ScriptHookPlugin() = default; +ScriptHookPlugin::~ScriptHookPlugin() = default; + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::QueryInterface(REFIID riid, void** ppvObject) +{ + if (ppvObject == nullptr) + { + return E_POINTER; + } + + *ppvObject = nullptr; + if (riid == __uuidof(IUnknown) || riid == __uuidof(IWTSPlugin)) + { + *ppvObject = static_cast(this); + AddRef(); + return S_OK; + } + + return E_NOINTERFACE; +} + +ULONG STDMETHODCALLTYPE ScriptHookPlugin::AddRef() +{ + return static_cast(InterlockedIncrement(&refCount_)); +} + +ULONG STDMETHODCALLTYPE ScriptHookPlugin::Release() +{ + ULONG remaining = static_cast(InterlockedDecrement(&refCount_)); + if (remaining == 0) + { + delete this; + } + return remaining; +} + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Initialize(IWTSVirtualChannelManager* channelManager) +{ + UNREFERENCED_PARAMETER(channelManager); + const hook::HookConfig config = hook::LoadConfig(); + hook::Log(config.enableLogging, L"IWTSPlugin::Initialize aufgerufen."); + return S_OK; +} + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Connected() +{ + const hook::HookConfig config = hook::LoadConfig(); + hook::Log(config.enableLogging, L"RDP-Verbindung hergestellt (IWTSPlugin::Connected)."); + + if (!config.enabled || !config.startOnConnect) + { + hook::Log(config.enableLogging, L"Start ist in der Konfiguration deaktiviert."); + return S_OK; + } + + // Nur Registry lesen + CreateProcess aufrufen. Es wird niemals auf das Script gewartet. + launcher_.Start(config); + return S_OK; +} + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Disconnected(DWORD disconnectCode) +{ + const hook::HookConfig config = hook::LoadConfig(); + hook::Log(config.enableLogging, + L"RDP-Verbindung getrennt (IWTSPlugin::Disconnected), Code=" + std::to_wstring(disconnectCode) + L"."); + + if (config.stopOnDisconnect) + { + launcher_.Stop(config.enableLogging); + } + return S_OK; +} + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Terminated() +{ + const hook::HookConfig config = hook::LoadConfig(); + hook::Log(config.enableLogging, L"IWTSPlugin::Terminated aufgerufen."); + + if (config.stopOnDisconnect) + { + launcher_.Stop(config.enableLogging); + } + else + { + launcher_.Detach(config.enableLogging); + } + return S_OK; +} diff --git a/Plugin/ScriptHookPlugin.h b/Plugin/ScriptHookPlugin.h new file mode 100644 index 0000000..450797d --- /dev/null +++ b/Plugin/ScriptHookPlugin.h @@ -0,0 +1,26 @@ +#pragma once + +#include "ProcessLauncher.h" +#include +#include + +class ScriptHookPlugin final : public IWTSPlugin +{ +public: + ScriptHookPlugin(); + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override; + ULONG STDMETHODCALLTYPE AddRef() override; + ULONG STDMETHODCALLTYPE Release() override; + + HRESULT STDMETHODCALLTYPE Initialize(IWTSVirtualChannelManager* channelManager) override; + HRESULT STDMETHODCALLTYPE Connected() override; + HRESULT STDMETHODCALLTYPE Disconnected(DWORD disconnectCode) override; + HRESULT STDMETHODCALLTYPE Terminated() override; + +private: + ~ScriptHookPlugin(); + + LONG refCount_ = 1; + hook::ProcessLauncher launcher_; +}; diff --git a/Plugin/exports.def b/Plugin/exports.def new file mode 100644 index 0000000..4b63574 --- /dev/null +++ b/Plugin/exports.def @@ -0,0 +1,3 @@ +LIBRARY "PlandentMstscScriptHook" +EXPORTS + VirtualChannelGetInstance diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5653a3 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Plandent MSTSC Script Hook + +Kleines Windows-Projekt, das beim erfolgreichen Aufbau einer klassischen RDP-Verbindung mit `mstsc.exe` lokal ein konfiguriertes Script startet. + +## Komponenten + +- **PlandentMstscScriptHook.dll** – native x64 DVC-Plugin-DLL für den Remote Desktop Connection Client. +- **PlandentMstscScriptHook.exe** – WinForms-Konfigurator/Installer. Die Release-EXE enthält die DLL als Resource und extrahiert sie bei der Installation. + +## Verhalten + +Die DLL implementiert `IWTSPlugin` und exportiert `VirtualChannelGetInstance`. + +- `Initialize()` – Plugin wird von MSTSC initialisiert. +- `Connected()` – liest die Konfiguration und startet das lokale Script. +- `Disconnected()` – beendet das von dieser Plugin-Instanz gestartete Script, wenn `StopOnDisconnect=1` gesetzt ist. +- `Terminated()` – bereinigt Prozesshandles; bei `StopOnDisconnect=0` läuft das Script weiter. + +Der Plugin-Callback wartet **nicht** auf das Script. Es wird nur mit `CreateProcessW` gestartet und der Callback kehrt direkt zurück. + +## Registry + +MSTSC-Registrierung pro Benutzer: + +```text +HKCU\Software\Microsoft\Terminal Server Client\Default\AddIns\PlandentMstscScriptHook + Name = C:\Users\\AppData\Local\Plandent\MstscScriptHook\PlandentMstscScriptHook.dll +``` + +Konfiguration: + +```text +HKCU\Software\Plandent\MstscScriptHook + Enabled REG_DWORD 1 + ScriptPath REG_EXPAND_SZ C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat + Arguments REG_SZ MIN + WorkingDirectory REG_EXPAND_SZ C:\RemoteVDDS + StartOnConnect REG_DWORD 1 + StopOnDisconnect REG_DWORD 0 + Hidden REG_DWORD 1 + PreventDuplicates REG_DWORD 1 + EnableLogging REG_DWORD 1 +``` + +## Unterstützte Starttypen + +- `.bat` / `.cmd` → `cmd.exe /D /S /C ...` +- `.ps1` → Windows PowerShell mit `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` +- `.exe` → direkter Start + +Bei `Hidden=1` wird `CREATE_NO_WINDOW` verwendet und `SW_HIDE` gesetzt. + +## Mehrfachstartschutz + +Bei `PreventDuplicates=1` wird aus dem expandierten Scriptpfad ein Named Mutex erzeugt. Das Mutex-Handle wird gezielt an den gestarteten Prozess vererbt. Damit bleibt der Schutz bestehen, solange der Receiver läuft – auch wenn die ursprüngliche `mstsc.exe`-Instanz bereits beendet wurde. + +Der Schutz ist bewusst `Local\\...`, also auf die lokale Windows-Sitzung begrenzt. + +## Logging + +Bei aktiviertem Logging: + +```text +%LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log +``` + +Die GUI enthält einen Button **Log öffnen**. + +## Voraussetzungen zum Bauen + +- Windows 10/11 x64 +- Visual Studio 2022 oder Visual Studio Build Tools 2022 +- Workload **Desktopentwicklung mit C++** +- Windows 10/11 SDK +- .NET 8 SDK + +## Release bauen + +PowerShell im Projektverzeichnis: + +```powershell +.\build-release.ps1 -Clean +``` + +Ergebnis: + +```text +dist\PlandentMstscScriptHook.exe +``` + +Die veröffentlichte EXE ist x64, self-contained und enthält die native Plugin-DLL als Resource. + +## Installation + +1. `PlandentMstscScriptHook.exe` starten. +2. Script über `...` auswählen. +3. Für `RemoteVDDS_Receiver_TS.bat` als Argument `MIN` setzen. +4. Optional Arbeitsverzeichnis auswählen; standardmäßig sollte es dem Scriptverzeichnis entsprechen. +5. **Script testen** und anschließend **Test beenden**. +6. **Plugin installieren** anklicken. +7. Alle bereits laufenden `mstsc.exe`-Instanzen schließen. +8. MSTSC neu starten und eine RDP-Verbindung herstellen. + +Die Installation erfolgt nur unter `HKCU` und `%LOCALAPPDATA%`; dafür sind normalerweise keine Administratorrechte nötig. + +## Deinstallation + +**Plugin entfernen** löscht den MSTSC-Registryeintrag und die installierte DLL. Ist die DLL noch in einer laufenden `mstsc.exe` geladen, wird zumindest die Registrierung entfernt; nach dem Schließen aller MSTSC-Instanzen kann die DLL erneut über den Button entfernt werden. + +Die Script-Konfiguration unter `HKCU\Software\Plandent\MstscScriptHook` bleibt absichtlich erhalten. + +## Wichtige Grenzen + +- Das Plugin ist für den klassischen Microsoft RDC-Pluginmechanismus ausgelegt. Andere RDP-Clients müssen diesen Mechanismus ebenfalls unterstützen, sonst wird die DLL nicht geladen. +- `StopOnDisconnect` beendet nur den vom Plugin selbst gestarteten Root-Prozess. Beim RemoteVDDS-BAT ist das der dauerhaft laufende `cmd.exe`-Receiver. +- Das Script läuft mit den Rechten des lokalen Benutzers, der `mstsc.exe` gestartet hat. +- AppLocker/WDAC oder andere Application-Control-Richtlinien können das Laden einer nicht signierten DLL bzw. das Starten des Scripts blockieren. diff --git a/build-release.ps1 b/build-release.ps1 new file mode 100644 index 0000000..d494cbb --- /dev/null +++ b/build-release.ps1 @@ -0,0 +1,72 @@ +[CmdletBinding()] +param( + [ValidateSet('Release','Debug')] + [string]$Configuration = 'Release', + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSCommandPath +$PluginProject = Join-Path $Root 'Plugin\PlandentMstscScriptHook.vcxproj' +$ConfiguratorProject = Join-Path $Root 'Configurator\Configurator.csproj' +$Dist = Join-Path $Root 'dist' +$Artifacts = Join-Path $Root 'artifacts' + +function Find-MSBuild { + $cmd = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $vswhere) { + $found = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1 + if ($found) { return $found } + } + + throw 'MSBuild wurde nicht gefunden. Visual Studio 2022 bzw. Build Tools mit Desktopentwicklung mit C++ installieren.' +} + +if ($Clean) { + Remove-Item $Artifacts -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $Dist -Recurse -Force -ErrorAction SilentlyContinue +} + +New-Item -ItemType Directory -Path $Dist -Force | Out-Null + +$MSBuild = Find-MSBuild +Write-Host "[1/2] Native MSTSC Plugin-DLL bauen ($Configuration|x64)..." -ForegroundColor Cyan +& $MSBuild $PluginProject /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64' +if ($LASTEXITCODE -ne 0) { + throw "Native DLL Build fehlgeschlagen (ExitCode $LASTEXITCODE)." +} + +$PluginDll = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.dll" +if (-not (Test-Path $PluginDll)) { + throw "Erwartete DLL wurde nicht erzeugt: $PluginDll" +} + +Write-Host "[2/2] Konfigurations-EXE als x64 Single-File veröffentlichen..." -ForegroundColor Cyan +& dotnet publish $ConfiguratorProject ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:Platform=x64 ` + -p:PublishSingleFile=true ` + -p:DebugType=embedded ` + -p:DebugSymbols=false ` + -o $Dist +if ($LASTEXITCODE -ne 0) { + throw "Configurator Publish fehlgeschlagen (ExitCode $LASTEXITCODE)." +} + +$Exe = Join-Path $Dist 'PlandentMstscScriptHook.exe' +if (-not (Test-Path $Exe)) { + throw "Erwartete EXE wurde nicht erzeugt: $Exe" +} + +Write-Host '' +Write-Host 'Build erfolgreich:' -ForegroundColor Green +Write-Host " $Exe" +Write-Host '' +Write-Host 'Diese EXE enthält die native DLL als eingebettete Resource.' diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f6171f6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,13 @@ +# RemoteVDDS-Beispiel + +Für `RemoteVDDS_Receiver_TS.bat` empfiehlt sich: + +- Script: Pfad zu `RemoteVDDS_Receiver_TS.bat` +- Argumente: `MIN` +- Arbeitsverzeichnis: Verzeichnis des BAT-Scripts +- Start bei RDP-Verbindung: an +- Bei Trennung beenden: aus (bei Bedarf einschalten) +- Unsichtbar starten: an +- Mehrfachstart verhindern: an + +Das Argument `MIN` ist wichtig, weil das vorhandene BAT-Script ohne diesen Parameter selbst noch einmal per `start /MIN` gestartet wird. Der MSTSC Script Hook startet den Receiver bereits unsichtbar, daher wird der zweite Start nicht benötigt. diff --git a/examples/RemoteVDDS-Preset.reg b/examples/RemoteVDDS-Preset.reg new file mode 100644 index 0000000..13f17ca --- /dev/null +++ b/examples/RemoteVDDS-Preset.reg @@ -0,0 +1,12 @@ +Windows Registry Editor Version 5.00 + +[HKEY_CURRENT_USER\Software\Plandent\MstscScriptHook] +"Enabled"=dword:00000001 +"ScriptPath"="C:\\RemoteVDDS\\RemoteVDDS_Receiver_TS.bat" +"Arguments"="MIN" +"WorkingDirectory"="C:\\RemoteVDDS" +"StartOnConnect"=dword:00000001 +"StopOnDisconnect"=dword:00000000 +"Hidden"=dword:00000001 +"PreventDuplicates"=dword:00000001 +"EnableLogging"=dword:00000001