diff --git a/Configurator/Configurator.csproj b/Configurator/Configurator.csproj index a2c2273..97557a4 100644 --- a/Configurator/Configurator.csproj +++ b/Configurator/Configurator.csproj @@ -9,12 +9,24 @@ Plandent.MstscScriptHook.Configurator x64 x64 + $(MSBuildProjectDirectory)\..\artifacts\x64\$(Configuration)\PlandentMstscScriptHook.dll + + + + + false + false + portable + + + + win-x64 true true false embedded - $(MSBuildProjectDirectory)\..\artifacts\x64\$(Configuration)\PlandentMstscScriptHook.dll diff --git a/Configurator/HookConfig.cs b/Configurator/HookConfig.cs index 0f347ab..1713e93 100644 --- a/Configurator/HookConfig.cs +++ b/Configurator/HookConfig.cs @@ -3,6 +3,9 @@ namespace Plandent.MstscScriptHook.Configurator; internal sealed class HookConfig { public bool Enabled { get; set; } = true; + public bool EnableLogging { get; set; } = true; + + // Automatischer Start bei erfolgreicher RDP-Verbindung. public string ScriptPath { get; set; } = string.Empty; public string Arguments { get; set; } = string.Empty; public string WorkingDirectory { get; set; } = string.Empty; @@ -10,5 +13,13 @@ internal sealed class HookConfig public bool StopOnDisconnect { get; set; } public bool Hidden { get; set; } = true; public bool PreventDuplicates { get; set; } = true; - public bool EnableLogging { get; set; } = true; + + // Separates lokales Programm, das ausschließlich durch den festen + // START-Trigger vom Terminalserver gestartet werden kann. + public bool TriggerEnabled { get; set; } + public string TriggerProgramPath { get; set; } = string.Empty; + public string TriggerArguments { get; set; } = string.Empty; + public string TriggerWorkingDirectory { get; set; } = string.Empty; + public bool TriggerHidden { get; set; } = true; + public bool TriggerPreventDuplicates { get; set; } = true; } diff --git a/Configurator/MainForm.cs b/Configurator/MainForm.cs index aea4a2d..3980cb5 100644 --- a/Configurator/MainForm.cs +++ b/Configurator/MainForm.cs @@ -1,21 +1,35 @@ +using System.Diagnostics; using System.Drawing; using System.Windows.Forms; -using System.Diagnostics; namespace Plandent.MstscScriptHook.Configurator; internal sealed class MainForm : Form { + // Global + private readonly CheckBox enabledCheckBox = new() { Text = "Plugin-Funktion aktiviert", AutoSize = true }; + private readonly CheckBox loggingCheckBox = new() { Text = "Plugin-Logging aktivieren", AutoSize = true }; + + // Automatischer Start bei RDP-Verbindung 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 startOnConnectCheckBox = new() { Text = "Bei erfolgreicher RDP-Verbindung starten", AutoSize = true }; + private readonly CheckBox stopOnDisconnectCheckBox = new() { Text = "Bei RDP-Trennung beenden", AutoSize = true }; + private readonly CheckBox hiddenCheckBox = new() { Text = "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 Button stopConnectionTestButton = new() { Text = "Test beenden", AutoSize = true, Enabled = false }; + private Process? connectionTestProcess; + + // Separater Start durch Terminalserver-Trigger + private readonly TextBox triggerProgramPathTextBox = new() { Dock = DockStyle.Fill }; + private readonly TextBox triggerArgumentsTextBox = new() { Dock = DockStyle.Fill }; + private readonly TextBox triggerWorkingDirectoryTextBox = new() { Dock = DockStyle.Fill }; + private readonly CheckBox triggerEnabledCheckBox = new() { Text = "Start durch Terminalserver-Trigger erlauben", AutoSize = true }; + private readonly CheckBox triggerHiddenCheckBox = new() { Text = "Unsichtbar starten", AutoSize = true }; + private readonly CheckBox triggerPreventDuplicatesCheckBox = new() { Text = "Mehrfachstart verhindern", AutoSize = true }; + private readonly Button stopTriggerTestButton = new() { Text = "Test beenden", AutoSize = true, Enabled = false }; + private Process? triggerTestProcess; private readonly Label statusLabel = new() { @@ -25,20 +39,17 @@ internal sealed class MainForm : Form 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); + MinimumSize = new Size(820, 720); + Size = new Size(900, 800); Font = new Font("Segoe UI", 9F); Controls.Add(BuildMainLayout()); Load += (_, _) => LoadConfiguration(); - FormClosed += (_, _) => DisposeTestProcessHandle(); + FormClosed += (_, _) => DisposeAllTestProcesses(); } private Control BuildMainLayout() @@ -52,10 +63,10 @@ internal sealed class MainForm : Form }; 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)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var title = new Label { @@ -68,21 +79,201 @@ internal sealed class MainForm : Form var description = new Label { - Text = "Startet bei einer erfolgreichen klassischen Remotedesktop-Verbindung lokal ein konfiguriertes Script im Hintergrund.", + Text = "Lokale Programme können automatisch beim RDP-Verbindungsaufbau oder gezielt durch eine Anwendung innerhalb der Terminalserver-Sitzung gestartet werden.", AutoSize = true, - MaximumSize = new Size(740, 0), - Margin = new Padding(0, 0, 0, 16) + MaximumSize = new Size(820, 0), + Margin = new Padding(0, 0, 0, 12) }; 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); + var scroll = new Panel + { + Dock = DockStyle.Fill, + AutoScroll = true, + Margin = new Padding(0, 0, 0, 12) + }; + + var content = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 1, + RowCount = 3 + }; + content.Controls.Add(BuildGlobalOptionsGroup(), 0, 0); + content.Controls.Add(BuildConnectionProgramGroup(), 0, 1); + content.Controls.Add(BuildTriggerProgramGroup(), 0, 2); + scroll.Controls.Add(content); + root.Controls.Add(scroll, 0, 2); + + root.Controls.Add(BuildStatusPanel(), 0, 3); + + var hint = new Label + { + Text = "Hinweis: PlandentRdpClientTrigger.exe auf dem Terminalserver übermittelt ausschließlich einen festen START-Befehl. Programmpfad und Parameter werden niemals vom Server übertragen.", + AutoSize = true, + MaximumSize = new Size(820, 0), + Margin = new Padding(0, 0, 0, 10) + }; + root.Controls.Add(hint, 0, 4); + + root.Controls.Add(BuildMainButtons(), 0, 5); return root; } - private Control BuildFileGrid() + private Control BuildGlobalOptionsGroup() + { + var group = CreateGroup("Allgemein"); + var options = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoSize = true + }; + options.Controls.Add(enabledCheckBox); + options.Controls.Add(loggingCheckBox); + group.Controls.Add(options); + return group; + } + + private Control BuildConnectionProgramGroup() + { + var group = CreateGroup("Automatischer Start bei RDP-Verbindung"); + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 1, + RowCount = 4 + }; + + layout.Controls.Add(new Label + { + Text = "Optionales lokales Programm/Script, das direkt nach erfolgreichem Aufbau der klassischen MSTSC-Verbindung gestartet wird.", + AutoSize = true, + MaximumSize = new Size(780, 0), + Margin = new Padding(0, 0, 0, 8) + }, 0, 0); + + layout.Controls.Add(BuildFileGrid( + scriptPathTextBox, + argumentsTextBox, + workingDirectoryTextBox, + () => BrowseProgram(scriptPathTextBox, workingDirectoryTextBox, useRemoteVddsPreset: true), + () => BrowseWorkingDirectory(workingDirectoryTextBox)), 0, 1); + + var options = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + AutoSize = true, + Margin = new Padding(0, 4, 0, 6) + }; + options.Controls.Add(startOnConnectCheckBox); + options.Controls.Add(stopOnDisconnectCheckBox); + options.Controls.Add(hiddenCheckBox); + options.Controls.Add(preventDuplicatesCheckBox); + layout.Controls.Add(options, 0, 2); + + var testButtons = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + AutoSize = true, + Margin = new Padding(0) + }; + var testButton = new Button { Text = "Verbindungs-Programm testen", AutoSize = true }; + testButton.Click += (_, _) => StartConnectionTest(); + stopConnectionTestButton.Click += (_, _) => StopConnectionTest(silent: false); + testButtons.Controls.Add(testButton); + testButtons.Controls.Add(stopConnectionTestButton); + layout.Controls.Add(testButtons, 0, 3); + + group.Controls.Add(layout); + return group; + } + + private Control BuildTriggerProgramGroup() + { + var group = CreateGroup("Programmstart durch Terminalserver"); + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 1, + RowCount = 4 + }; + + layout.Controls.Add(new Label + { + Text = "Optionales lokales Programm/Script mit eigenen Parametern. Es wird nur gestartet, wenn PlandentRdpClientTrigger.exe innerhalb derselben RDP-Sitzung auf dem Terminalserver ausgeführt wird.", + AutoSize = true, + MaximumSize = new Size(780, 0), + Margin = new Padding(0, 0, 0, 8) + }, 0, 0); + + layout.Controls.Add(BuildFileGrid( + triggerProgramPathTextBox, + triggerArgumentsTextBox, + triggerWorkingDirectoryTextBox, + () => BrowseProgram(triggerProgramPathTextBox, triggerWorkingDirectoryTextBox, useRemoteVddsPreset: false), + () => BrowseWorkingDirectory(triggerWorkingDirectoryTextBox)), 0, 1); + + var options = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + AutoSize = true, + Margin = new Padding(0, 4, 0, 6) + }; + options.Controls.Add(triggerEnabledCheckBox); + options.Controls.Add(triggerHiddenCheckBox); + options.Controls.Add(triggerPreventDuplicatesCheckBox); + layout.Controls.Add(options, 0, 2); + + var testButtons = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + AutoSize = true, + Margin = new Padding(0) + }; + var testButton = new Button { Text = "Trigger-Programm testen", AutoSize = true }; + testButton.Click += (_, _) => StartTriggerTest(); + stopTriggerTestButton.Click += (_, _) => StopTriggerTest(silent: false); + testButtons.Controls.Add(testButton); + testButtons.Controls.Add(stopTriggerTestButton); + layout.Controls.Add(testButtons, 0, 3); + + group.Controls.Add(layout); + return group; + } + + private static GroupBox CreateGroup(string title) + { + return new GroupBox + { + Text = title, + Dock = DockStyle.Top, + AutoSize = true, + Padding = new Padding(12), + Margin = new Padding(0, 0, 0, 12) + }; + } + + private static Control BuildFileGrid( + TextBox pathTextBox, + TextBox argumentsTextBox, + TextBox workingDirectoryTextBox, + Action browseProgram, + Action browseWorkingDirectory) { var grid = new TableLayoutPanel { @@ -90,17 +281,17 @@ internal sealed class MainForm : Form AutoSize = true, ColumnCount = 3, RowCount = 3, - Margin = new Padding(0, 0, 0, 14) + Margin = new Padding(0) }; 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("Programm/Script:"), 0, 0); + grid.Controls.Add(pathTextBox, 1, 0); + var browseProgramButton = new Button { Text = "…", Width = 42, Height = pathTextBox.PreferredHeight + 4 }; + browseProgramButton.Click += (_, _) => browseProgram(); + grid.Controls.Add(browseProgramButton, 2, 0); grid.Controls.Add(CreateFieldLabel("Argumente:"), 0, 1); grid.Controls.Add(argumentsTextBox, 1, 1); @@ -109,7 +300,7 @@ internal sealed class MainForm : Form 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(); + browseWorkingButton.Click += (_, _) => browseWorkingDirectory(); grid.Controls.Add(browseWorkingButton, 2, 2); return grid; @@ -126,48 +317,20 @@ internal sealed class MainForm : Form }; } - 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, + Height = 70, BorderStyle = BorderStyle.FixedSingle, - Margin = new Padding(0, 0, 0, 14) + Margin = new Padding(0, 0, 0, 10) }; panel.Controls.Add(statusLabel); return panel; } - private Control BuildButtons() + private Control BuildMainButtons() { var buttons = new FlowLayoutPanel { @@ -178,10 +341,6 @@ internal sealed class MainForm : Form 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); @@ -194,8 +353,6 @@ internal sealed class MainForm : Form 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); @@ -203,18 +360,18 @@ internal sealed class MainForm : Form return buttons; } - private void BrowseScript() + private void BrowseProgram(TextBox pathTextBox, TextBox workingDirectoryTextBox, bool useRemoteVddsPreset) { 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 (*.*)|*.*", + Title = "Programm oder Script auswählen", + Filter = "Unterstützte Dateien (*.exe;*.bat;*.cmd;*.ps1)|*.exe;*.bat;*.cmd;*.ps1|Alle Dateien (*.*)|*.*", CheckFileExists = true }; - if (!string.IsNullOrWhiteSpace(scriptPathTextBox.Text)) + if (!string.IsNullOrWhiteSpace(pathTextBox.Text)) { - string expanded = Environment.ExpandEnvironmentVariables(scriptPathTextBox.Text); + string expanded = Environment.ExpandEnvironmentVariables(pathTextBox.Text); if (File.Exists(expanded)) { dialog.InitialDirectory = Path.GetDirectoryName(expanded); @@ -227,20 +384,21 @@ internal sealed class MainForm : Form return; } - scriptPathTextBox.Text = dialog.FileName; + pathTextBox.Text = dialog.FileName; if (string.IsNullOrWhiteSpace(workingDirectoryTextBox.Text)) { workingDirectoryTextBox.Text = Path.GetDirectoryName(dialog.FileName) ?? string.Empty; } - if (string.IsNullOrWhiteSpace(argumentsTextBox.Text) && + if (useRemoteVddsPreset && + string.IsNullOrWhiteSpace(argumentsTextBox.Text) && Path.GetFileName(dialog.FileName).Contains("RemoteVDDS_Receiver_TS", StringComparison.OrdinalIgnoreCase)) { argumentsTextBox.Text = "MIN"; } } - private void BrowseWorkingDirectory() + private void BrowseWorkingDirectory(TextBox target) { using var dialog = new FolderBrowserDialog { @@ -249,7 +407,7 @@ internal sealed class MainForm : Form ShowNewFolderButton = true }; - string expanded = Environment.ExpandEnvironmentVariables(workingDirectoryTextBox.Text); + string expanded = Environment.ExpandEnvironmentVariables(target.Text); if (Directory.Exists(expanded)) { dialog.SelectedPath = expanded; @@ -257,7 +415,7 @@ internal sealed class MainForm : Form if (dialog.ShowDialog(this) == DialogResult.OK) { - workingDirectoryTextBox.Text = dialog.SelectedPath; + target.Text = dialog.SelectedPath; } } @@ -266,6 +424,8 @@ internal sealed class MainForm : Form return new HookConfig { Enabled = enabledCheckBox.Checked, + EnableLogging = loggingCheckBox.Checked, + ScriptPath = scriptPathTextBox.Text.Trim(), Arguments = argumentsTextBox.Text.Trim(), WorkingDirectory = workingDirectoryTextBox.Text.Trim(), @@ -273,22 +433,37 @@ internal sealed class MainForm : Form StopOnDisconnect = stopOnDisconnectCheckBox.Checked, Hidden = hiddenCheckBox.Checked, PreventDuplicates = preventDuplicatesCheckBox.Checked, - EnableLogging = loggingCheckBox.Checked + + TriggerEnabled = triggerEnabledCheckBox.Checked, + TriggerProgramPath = triggerProgramPathTextBox.Text.Trim(), + TriggerArguments = triggerArgumentsTextBox.Text.Trim(), + TriggerWorkingDirectory = triggerWorkingDirectoryTextBox.Text.Trim(), + TriggerHidden = triggerHiddenCheckBox.Checked, + TriggerPreventDuplicates = triggerPreventDuplicatesCheckBox.Checked }; } private void LoadConfiguration() { HookConfig config = RegistryConfig.Load(); + enabledCheckBox.Checked = config.Enabled; + loggingCheckBox.Checked = config.EnableLogging; + 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; + + triggerEnabledCheckBox.Checked = config.TriggerEnabled; + triggerProgramPathTextBox.Text = config.TriggerProgramPath; + triggerArgumentsTextBox.Text = config.TriggerArguments; + triggerWorkingDirectoryTextBox.Text = config.TriggerWorkingDirectory; + triggerHiddenCheckBox.Checked = config.TriggerHidden; + triggerPreventDuplicatesCheckBox.Checked = config.TriggerPreventDuplicates; + RefreshStatus(); } @@ -297,7 +472,7 @@ internal sealed class MainForm : Form try { HookConfig config = ReadForm(); - ValidateConfig(config, requireScript: false); + ValidateConfiguredTargets(config, installationValidation: false); RegistryConfig.Save(config); if (showConfirmation) { @@ -318,14 +493,14 @@ internal sealed class MainForm : Form try { HookConfig config = ReadForm(); - ValidateConfig(config, requireScript: true); + ValidateConfiguredTargets(config, installationValidation: 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.", + "Bereits geöffnete mstsc.exe-Fenster vollständig schließen und anschließend neu starten, damit die neue DLL geladen wird.", "Installation abgeschlossen", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -348,63 +523,92 @@ internal sealed class MainForm : Form result.FullyRemoved ? MessageBoxIcon.Information : MessageBoxIcon.Warning); } - private void StartTest() + private void StartConnectionTest() { try { - StopTest(silent: true); + StopConnectionTest(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."; + ValidateLaunchTarget(config.ScriptPath, config.WorkingDirectory, "RDP-Verbindungsprogramm", required: true); + connectionTestProcess = ScriptTestLauncher.StartConnectionProgram(config); + stopConnectionTestButton.Enabled = true; + statusLabel.Text = $"Verbindungs-Test gestartet: PID {connectionTestProcess.Id}. Der Test läuft unabhängig von MSTSC."; } catch (Exception ex) { - MessageBox.Show(this, ex.Message, "Script-Test fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show(this, ex.Message, "Test fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error); } } - private void StopTest() => StopTest(silent: false); - - private void StopTest(bool silent) + private void StartTriggerTest() { - if (testProcess is null) + try { - stopTestButton.Enabled = false; + StopTriggerTest(silent: true); + HookConfig config = ReadForm(); + ValidateLaunchTarget(config.TriggerProgramPath, config.TriggerWorkingDirectory, "Trigger-Programm", required: true); + triggerTestProcess = ScriptTestLauncher.StartTriggerProgram(config); + stopTriggerTestButton.Enabled = true; + statusLabel.Text = $"Trigger-Programm-Test gestartet: PID {triggerTestProcess.Id}. Der Test läuft ohne Terminalserver-Trigger."; + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Trigger-Programm-Test fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void StopConnectionTest(bool silent) + { + StopTestProcess(ref connectionTestProcess, stopConnectionTestButton, "Verbindungs-Test", silent); + } + + private void StopTriggerTest(bool silent) + { + StopTestProcess(ref triggerTestProcess, stopTriggerTestButton, "Trigger-Programm-Test", silent); + } + + private void StopTestProcess(ref Process? process, Button button, string description, bool silent) + { + if (process is null) + { + button.Enabled = false; return; } try { - if (!testProcess.HasExited) + if (!process.HasExited) { - testProcess.Kill(entireProcessTree: true); - testProcess.WaitForExit(3000); + process.Kill(entireProcessTree: true); + process.WaitForExit(3000); } + if (!silent) { - statusLabel.Text = "Testprozess wurde beendet."; + statusLabel.Text = description + " wurde beendet."; } } catch (Exception ex) { if (!silent) { - MessageBox.Show(this, ex.Message, "Testprozess konnte nicht beendet werden", MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBox.Show(this, ex.Message, description + " konnte nicht beendet werden", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } finally { - DisposeTestProcessHandle(); + process?.Dispose(); + process = null; + button.Enabled = false; } } - private void DisposeTestProcessHandle() + private void DisposeAllTestProcesses() { - testProcess?.Dispose(); - testProcess = null; - stopTestButton.Enabled = false; + connectionTestProcess?.Dispose(); + triggerTestProcess?.Dispose(); + connectionTestProcess = null; + triggerTestProcess = null; } private void OpenLog() @@ -426,42 +630,69 @@ internal sealed class MainForm : Form 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; + + string connectStatus = startOnConnectCheckBox.Checked + ? (string.IsNullOrWhiteSpace(scriptPathTextBox.Text) ? " RDP-Start: nicht konfiguriert." : " RDP-Start: konfiguriert.") + : " RDP-Start: aus."; + + string triggerStatus = triggerEnabledCheckBox.Checked + ? (string.IsNullOrWhiteSpace(triggerProgramPathTextBox.Text) ? " Server-Trigger: nicht konfiguriert." : " Server-Trigger: konfiguriert.") + : " Server-Trigger: aus."; + + statusLabel.Text = (installed ? "✓ " : "○ ") + pluginStatus + connectStatus + triggerStatus; } - private static void ValidateConfig(HookConfig config, bool requireScript) + private static void ValidateConfiguredTargets(HookConfig config, bool installationValidation) { - if (!requireScript && string.IsNullOrWhiteSpace(config.ScriptPath)) + // Bereits ausgefüllte Pfade immer prüfen, damit Tippfehler nicht gespeichert werden. + ValidateLaunchTarget(config.ScriptPath, config.WorkingDirectory, "RDP-Verbindungsprogramm", required: false); + ValidateLaunchTarget(config.TriggerProgramPath, config.TriggerWorkingDirectory, "Trigger-Programm", required: false); + + if (!installationValidation || !config.Enabled) { return; } - string scriptPath = Environment.ExpandEnvironmentVariables(config.ScriptPath); - if (string.IsNullOrWhiteSpace(scriptPath)) + if (config.StartOnConnect) { - throw new InvalidOperationException("Bitte ein Script auswählen."); + ValidateLaunchTarget(config.ScriptPath, config.WorkingDirectory, "RDP-Verbindungsprogramm", required: true); } - if (!File.Exists(scriptPath)) + if (config.TriggerEnabled) { - throw new FileNotFoundException("Das ausgewählte Script wurde nicht gefunden.", scriptPath); + ValidateLaunchTarget(config.TriggerProgramPath, config.TriggerWorkingDirectory, "Trigger-Programm", required: true); + } + } + + private static void ValidateLaunchTarget(string configuredPath, string configuredWorkingDirectory, string displayName, bool required) + { + string path = Environment.ExpandEnvironmentVariables(configuredPath ?? string.Empty); + if (string.IsNullOrWhiteSpace(path)) + { + if (required) + { + throw new InvalidOperationException($"Bitte für „{displayName}“ ein Programm oder Script auswählen."); + } + return; } - string extension = Path.GetExtension(scriptPath).ToLowerInvariant(); + if (!File.Exists(path)) + { + throw new FileNotFoundException($"{displayName} wurde nicht gefunden.", path); + } + + string extension = Path.GetExtension(path).ToLowerInvariant(); if (extension is not (".bat" or ".cmd" or ".ps1" or ".exe")) { - throw new NotSupportedException("Unterstützt werden .bat, .cmd, .ps1 und .exe."); + throw new NotSupportedException($"{displayName}: Unterstützt werden .bat, .cmd, .ps1 und .exe."); } - if (!string.IsNullOrWhiteSpace(config.WorkingDirectory)) + if (!string.IsNullOrWhiteSpace(configuredWorkingDirectory)) { - string workDir = Environment.ExpandEnvironmentVariables(config.WorkingDirectory); + string workDir = Environment.ExpandEnvironmentVariables(configuredWorkingDirectory); if (!Directory.Exists(workDir)) { - throw new DirectoryNotFoundException("Das Arbeitsverzeichnis wurde nicht gefunden: " + workDir); + throw new DirectoryNotFoundException($"Arbeitsverzeichnis für „{displayName}“ wurde nicht gefunden: {workDir}"); } } } diff --git a/Configurator/RegistryConfig.cs b/Configurator/RegistryConfig.cs index 9acfd0c..393f13e 100644 --- a/Configurator/RegistryConfig.cs +++ b/Configurator/RegistryConfig.cs @@ -17,6 +17,8 @@ internal static class RegistryConfig } config.Enabled = ReadBool(key, "Enabled", true); + config.EnableLogging = ReadBool(key, "EnableLogging", true); + config.ScriptPath = ReadString(key, "ScriptPath"); config.Arguments = ReadString(key, "Arguments"); config.WorkingDirectory = ReadString(key, "WorkingDirectory"); @@ -24,7 +26,13 @@ internal static class RegistryConfig config.StopOnDisconnect = ReadBool(key, "StopOnDisconnect", false); config.Hidden = ReadBool(key, "Hidden", true); config.PreventDuplicates = ReadBool(key, "PreventDuplicates", true); - config.EnableLogging = ReadBool(key, "EnableLogging", true); + + config.TriggerEnabled = ReadBool(key, "TriggerEnabled", false); + config.TriggerProgramPath = ReadString(key, "TriggerProgramPath"); + config.TriggerArguments = ReadString(key, "TriggerArguments"); + config.TriggerWorkingDirectory = ReadString(key, "TriggerWorkingDirectory"); + config.TriggerHidden = ReadBool(key, "TriggerHidden", true); + config.TriggerPreventDuplicates = ReadBool(key, "TriggerPreventDuplicates", true); return config; } @@ -34,6 +42,8 @@ internal static class RegistryConfig ?? throw new InvalidOperationException("Registry-Schlüssel konnte nicht erstellt werden."); key.SetValue("Enabled", config.Enabled ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("EnableLogging", config.EnableLogging ? 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); @@ -41,7 +51,13 @@ internal static class RegistryConfig 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); + + key.SetValue("TriggerEnabled", config.TriggerEnabled ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("TriggerProgramPath", config.TriggerProgramPath ?? string.Empty, RegistryValueKind.ExpandString); + key.SetValue("TriggerArguments", config.TriggerArguments ?? string.Empty, RegistryValueKind.String); + key.SetValue("TriggerWorkingDirectory", config.TriggerWorkingDirectory ?? string.Empty, RegistryValueKind.ExpandString); + key.SetValue("TriggerHidden", config.TriggerHidden ? 1 : 0, RegistryValueKind.DWord); + key.SetValue("TriggerPreventDuplicates", config.TriggerPreventDuplicates ? 1 : 0, RegistryValueKind.DWord); } public static string? GetRegisteredPluginPath() diff --git a/Configurator/ScriptTestLauncher.cs b/Configurator/ScriptTestLauncher.cs index f65d7c1..759c232 100644 --- a/Configurator/ScriptTestLauncher.cs +++ b/Configurator/ScriptTestLauncher.cs @@ -4,24 +4,36 @@ namespace Plandent.MstscScriptHook.Configurator; internal static class ScriptTestLauncher { - public static Process Start(HookConfig config) + public static Process StartConnectionProgram(HookConfig config) => Start( + config.ScriptPath, + config.Arguments, + config.WorkingDirectory, + config.Hidden); + + public static Process StartTriggerProgram(HookConfig config) => Start( + config.TriggerProgramPath, + config.TriggerArguments, + config.TriggerWorkingDirectory, + config.TriggerHidden); + + private static Process Start(string configuredPath, string configuredArguments, string configuredWorkingDirectory, bool hidden) { - string scriptPath = Environment.ExpandEnvironmentVariables(config.ScriptPath ?? string.Empty); - if (string.IsNullOrWhiteSpace(scriptPath) || !File.Exists(scriptPath)) + string programPath = Environment.ExpandEnvironmentVariables(configuredPath ?? string.Empty); + if (string.IsNullOrWhiteSpace(programPath) || !File.Exists(programPath)) { - throw new FileNotFoundException("Das ausgewählte Script wurde nicht gefunden.", scriptPath); + throw new FileNotFoundException("Das ausgewählte Programm/Script wurde nicht gefunden.", programPath); } - string extension = Path.GetExtension(scriptPath).ToLowerInvariant(); - string workingDirectory = string.IsNullOrWhiteSpace(config.WorkingDirectory) - ? Path.GetDirectoryName(scriptPath) ?? Environment.CurrentDirectory - : Environment.ExpandEnvironmentVariables(config.WorkingDirectory); + string extension = Path.GetExtension(programPath).ToLowerInvariant(); + string workingDirectory = string.IsNullOrWhiteSpace(configuredWorkingDirectory) + ? Path.GetDirectoryName(programPath) ?? Environment.CurrentDirectory + : Environment.ExpandEnvironmentVariables(configuredWorkingDirectory); var startInfo = new ProcessStartInfo { UseShellExecute = false, - CreateNoWindow = config.Hidden, - WindowStyle = config.Hidden ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal, + CreateNoWindow = hidden, + WindowStyle = hidden ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal, WorkingDirectory = workingDirectory }; @@ -34,7 +46,7 @@ internal static class ScriptTestLauncher startInfo.ArgumentList.Add("/D"); startInfo.ArgumentList.Add("/S"); startInfo.ArgumentList.Add("/C"); - startInfo.ArgumentList.Add(BuildCmdCommand(scriptPath, config.Arguments)); + startInfo.ArgumentList.Add(BuildCmdCommand(programPath, configuredArguments)); break; case ".ps1": @@ -49,13 +61,13 @@ internal static class ScriptTestLauncher startInfo.ArgumentList.Add("-ExecutionPolicy"); startInfo.ArgumentList.Add("Bypass"); startInfo.ArgumentList.Add("-File"); - startInfo.ArgumentList.Add(scriptPath); - AddRawArguments(startInfo, config.Arguments); + startInfo.ArgumentList.Add(programPath); + AddRawArguments(startInfo, configuredArguments); break; case ".exe": - startInfo.FileName = scriptPath; - AddRawArguments(startInfo, config.Arguments); + startInfo.FileName = programPath; + AddRawArguments(startInfo, configuredArguments); break; default: @@ -66,9 +78,9 @@ internal static class ScriptTestLauncher ?? throw new InvalidOperationException("Der Testprozess konnte nicht gestartet werden."); } - private static string BuildCmdCommand(string scriptPath, string? arguments) + private static string BuildCmdCommand(string programPath, string? arguments) { - string command = QuoteForCmd(scriptPath); + string command = QuoteForCmd(programPath); if (!string.IsNullOrWhiteSpace(arguments)) { command += " " + arguments; @@ -81,9 +93,6 @@ internal static class ScriptTestLauncher 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)) diff --git a/PlandentMstscScriptHook.sln b/PlandentMstscScriptHook.sln index d018b54..4b6d3f6 100644 --- a/PlandentMstscScriptHook.sln +++ b/PlandentMstscScriptHook.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlandentMstscScriptHook.Con {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} = {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} EndProjectSection EndProject +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentRdpClientTrigger", "ServerTrigger\PlandentRdpClientTrigger.vcxproj", "{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -23,6 +25,10 @@ Global {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 + {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Debug|x64.ActiveCfg = Debug|x64 + {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Debug|x64.Build.0 = Debug|x64 + {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.ActiveCfg = Release|x64 + {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Plugin/PlandentMstscScriptHook.vcxproj b/Plugin/PlandentMstscScriptHook.vcxproj index 89850ec..ec915af 100644 --- a/Plugin/PlandentMstscScriptHook.vcxproj +++ b/Plugin/PlandentMstscScriptHook.vcxproj @@ -94,6 +94,7 @@ + diff --git a/Plugin/PlandentMstscScriptHook.vcxproj.filters b/Plugin/PlandentMstscScriptHook.vcxproj.filters index 037f4ff..3abf9ad 100644 --- a/Plugin/PlandentMstscScriptHook.vcxproj.filters +++ b/Plugin/PlandentMstscScriptHook.vcxproj.filters @@ -16,5 +16,6 @@ Header Files Header Files Header Files + Header Files diff --git a/Plugin/ProcessLauncher.cpp b/Plugin/ProcessLauncher.cpp index 2fc9506..764a8b0 100644 --- a/Plugin/ProcessLauncher.cpp +++ b/Plugin/ProcessLauncher.cpp @@ -122,9 +122,9 @@ namespace std::wstring commandLine; }; - bool BuildLaunchCommand(const hook::HookConfig& config, LaunchCommand& command) + bool BuildLaunchCommand(const hook::LaunchConfig& config, LaunchCommand& command) { - const std::wstring extension = GetExtensionLower(config.scriptPath); + const std::wstring extension = GetExtensionLower(config.path); if (extension == L".bat" || extension == L".cmd") { @@ -139,7 +139,7 @@ namespace comspec = systemRoot + L"\\System32\\cmd.exe"; } - std::wstring inner = QuoteWindowsArgument(config.scriptPath); + std::wstring inner = QuoteWindowsArgument(config.path); if (!config.arguments.empty()) { inner += L" " + config.arguments; @@ -162,7 +162,7 @@ namespace command.application = powershell; command.commandLine = QuoteWindowsArgument(powershell) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + - QuoteWindowsArgument(config.scriptPath); + QuoteWindowsArgument(config.path); if (!config.arguments.empty()) { command.commandLine += L" " + config.arguments; @@ -172,8 +172,8 @@ namespace if (extension == L".exe") { - command.application = config.scriptPath; - command.commandLine = QuoteWindowsArgument(config.scriptPath); + command.application = config.path; + command.commandLine = QuoteWindowsArgument(config.path); if (!config.arguments.empty()) { command.commandLine += L" " + config.arguments; @@ -217,37 +217,37 @@ namespace hook return process_ != nullptr; } - bool ProcessLauncher::Start(const HookConfig& config) + bool ProcessLauncher::Start(const LaunchConfig& config, bool loggingEnabled, const std::wstring& sourceName) { CleanupExitedProcess(); if (process_ != nullptr) { - Log(config.enableLogging, L"Script ist für diese MSTSC-Instanz bereits aktiv (PID " + std::to_wstring(processId_) + L")."); + Log(loggingEnabled, sourceName + L" ist für diese MSTSC-Instanz bereits aktiv (PID " + std::to_wstring(processId_) + L")."); return true; } - if (config.scriptPath.empty()) + if (config.path.empty()) { - Log(config.enableLogging, L"Kein ScriptPath konfiguriert; Start übersprungen."); + Log(loggingEnabled, L"Kein Pfad für " + sourceName + L" konfiguriert; Start übersprungen."); return false; } - DWORD attributes = GetFileAttributesW(config.scriptPath.c_str()); + DWORD attributes = GetFileAttributesW(config.path.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { - Log(config.enableLogging, L"Script wurde nicht gefunden: " + config.scriptPath); + Log(loggingEnabled, sourceName + L" wurde nicht gefunden: " + config.path); return false; } LaunchCommand command; if (!BuildLaunchCommand(config, command)) { - Log(config.enableLogging, L"Nicht unterstützter Script-/Dateityp: " + config.scriptPath); + Log(loggingEnabled, L"Nicht unterstützter Datei-/Programmtyp für " + sourceName + L": " + config.path); return false; } std::wstring workingDirectory = config.workingDirectory.empty() - ? ParentDirectory(config.scriptPath) + ? ParentDirectory(config.path) : config.workingDirectory; HANDLE duplicateMutex = nullptr; @@ -257,19 +257,19 @@ namespace hook sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; - std::wstring mutexName = MutexNameForScript(config.scriptPath); + std::wstring mutexName = MutexNameForScript(config.path); 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)); + Log(loggingEnabled, 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."); + Log(loggingEnabled, sourceName + L" läuft laut Mehrfachstart-Schutz bereits; kein weiterer Start."); return true; } } @@ -305,7 +305,7 @@ namespace hook { DWORD error = GetLastError(); CloseHandle(duplicateMutex); - Log(config.enableLogging, L"Prozessattributliste konnte nicht initialisiert werden: " + FormatWin32Error(error)); + Log(loggingEnabled, L"Prozessattributliste konnte nicht initialisiert werden: " + FormatWin32Error(error)); return false; } @@ -322,7 +322,7 @@ namespace hook DWORD error = GetLastError(); DeleteProcThreadAttributeList(startup.lpAttributeList); CloseHandle(duplicateMutex); - Log(config.enableLogging, L"Handle-Vererbung konnte nicht vorbereitet werden: " + FormatWin32Error(error)); + Log(loggingEnabled, L"Handle-Vererbung konnte nicht vorbereitet werden: " + FormatWin32Error(error)); return false; } @@ -371,7 +371,7 @@ namespace hook if (!created) { DWORD error = GetLastError(); - Log(config.enableLogging, L"Scriptstart fehlgeschlagen: " + FormatWin32Error(error)); + Log(loggingEnabled, sourceName + L" konnte nicht gestartet werden: " + FormatWin32Error(error)); return false; } @@ -379,14 +379,14 @@ namespace hook process_ = pi.hProcess; processId_ = pi.dwProcessId; - Log(config.enableLogging, - L"Script gestartet. PID=" + std::to_wstring(processId_) + - L", Script=" + config.scriptPath + + Log(loggingEnabled, + sourceName + L" gestartet. PID=" + std::to_wstring(processId_) + + L", Pfad=" + config.path + (config.arguments.empty() ? L"" : L", Argumente=" + config.arguments)); return true; } - void ProcessLauncher::Stop(bool loggingEnabled) + void ProcessLauncher::Stop(bool loggingEnabled, const std::wstring& sourceName) { CleanupExitedProcess(); if (process_ == nullptr) @@ -398,11 +398,11 @@ namespace hook if (!TerminateProcess(process_, 0)) { DWORD error = GetLastError(); - Log(loggingEnabled, L"Scriptprozess PID " + std::to_wstring(pid) + L" konnte nicht beendet werden: " + FormatWin32Error(error)); + Log(loggingEnabled, sourceName + L" 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."); + Log(loggingEnabled, L"Beenden von " + sourceName + L" PID " + std::to_wstring(pid) + L" wurde angefordert."); } CloseHandle(process_); @@ -410,7 +410,7 @@ namespace hook processId_ = 0; } - void ProcessLauncher::Detach(bool loggingEnabled) + void ProcessLauncher::Detach(bool loggingEnabled, const std::wstring& sourceName) { CleanupExitedProcess(); if (process_ == nullptr) @@ -418,7 +418,7 @@ namespace hook return; } - Log(loggingEnabled, L"MSTSC beendet Plugin; Script PID " + std::to_wstring(processId_) + L" läuft weiter."); + Log(loggingEnabled, L"MSTSC beendet Plugin; " + sourceName + L" 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 index 7755be5..3ea14bf 100644 --- a/Plugin/ProcessLauncher.h +++ b/Plugin/ProcessLauncher.h @@ -15,9 +15,9 @@ namespace hook ProcessLauncher(const ProcessLauncher&) = delete; ProcessLauncher& operator=(const ProcessLauncher&) = delete; - bool Start(const HookConfig& config); - void Stop(bool loggingEnabled); - void Detach(bool loggingEnabled); + bool Start(const LaunchConfig& config, bool loggingEnabled, const std::wstring& sourceName); + void Stop(bool loggingEnabled, const std::wstring& sourceName = L"Programm"); + void Detach(bool loggingEnabled, const std::wstring& sourceName = L"Programm"); bool IsRunning(); private: diff --git a/Plugin/RegistryConfig.cpp b/Plugin/RegistryConfig.cpp index 23aa062..6f0db9c 100644 --- a/Plugin/RegistryConfig.cpp +++ b/Plugin/RegistryConfig.cpp @@ -76,14 +76,24 @@ namespace hook } 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.enableLogging = ReadDword(key, L"EnableLogging", 1) != 0; + + // Bestehende Registry-Werte bleiben vollständig kompatibel. + config.connectProgram.path = ReadString(key, L"ScriptPath"); + config.connectProgram.arguments = ExpandEnvironment(ReadString(key, L"Arguments")); + config.connectProgram.workingDirectory = ReadString(key, L"WorkingDirectory"); + config.connectProgram.hidden = ReadDword(key, L"Hidden", 1) != 0; + config.connectProgram.preventDuplicates = ReadDword(key, L"PreventDuplicates", 1) != 0; 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; + + // Neue, ausschließlich lokal definierte Konfiguration für Server-Trigger. + config.triggerEnabled = ReadDword(key, L"TriggerEnabled", 0) != 0; + config.triggerProgram.path = ReadString(key, L"TriggerProgramPath"); + config.triggerProgram.arguments = ExpandEnvironment(ReadString(key, L"TriggerArguments")); + config.triggerProgram.workingDirectory = ReadString(key, L"TriggerWorkingDirectory"); + config.triggerProgram.hidden = ReadDword(key, L"TriggerHidden", 1) != 0; + config.triggerProgram.preventDuplicates = ReadDword(key, L"TriggerPreventDuplicates", 1) != 0; RegCloseKey(key); return config; diff --git a/Plugin/RegistryConfig.h b/Plugin/RegistryConfig.h index 78e152b..00b1766 100644 --- a/Plugin/RegistryConfig.h +++ b/Plugin/RegistryConfig.h @@ -7,17 +7,29 @@ namespace hook { inline constexpr wchar_t kConfigKey[] = L"Software\\Plandent\\MstscScriptHook"; + struct LaunchConfig + { + std::wstring path; + std::wstring arguments; + std::wstring workingDirectory; + bool hidden = true; + bool preventDuplicates = true; + }; + struct HookConfig { bool enabled = true; - std::wstring scriptPath; - std::wstring arguments; - std::wstring workingDirectory; + bool enableLogging = true; + + // Optionaler automatischer Start bei erfolgreicher RDP-Verbindung. + LaunchConfig connectProgram; bool startOnConnect = true; bool stopOnDisconnect = false; - bool hidden = true; - bool preventDuplicates = true; - bool enableLogging = true; + + // Optionaler Start, der durch PlandentRdpClientTrigger.exe auf dem + // Terminalserver über den Dynamic Virtual Channel ausgelöst wird. + bool triggerEnabled = false; + LaunchConfig triggerProgram; }; HookConfig LoadConfig(); diff --git a/Plugin/ScriptHookPlugin.cpp b/Plugin/ScriptHookPlugin.cpp index eaa5f7d..1cff37d 100644 --- a/Plugin/ScriptHookPlugin.cpp +++ b/Plugin/ScriptHookPlugin.cpp @@ -2,9 +2,109 @@ #include "Logging.h" #include "RegistryConfig.h" +#include "../Shared/TriggerProtocol.h" + +#include +#include +#include + +namespace +{ + class TriggerChannelCallback final : public IWTSVirtualChannelCallback + { + public: + TriggerChannelCallback() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override + { + if (ppvObject == nullptr) + { + return E_POINTER; + } + + *ppvObject = nullptr; + if (riid == __uuidof(IUnknown) || riid == __uuidof(IWTSVirtualChannelCallback)) + { + *ppvObject = static_cast(this); + AddRef(); + return S_OK; + } + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return static_cast(InterlockedIncrement(&refCount_)); + } + + ULONG STDMETHODCALLTYPE Release() override + { + ULONG remaining = static_cast(InterlockedDecrement(&refCount_)); + if (remaining == 0) + { + delete this; + } + return remaining; + } + + HRESULT STDMETHODCALLTYPE OnDataReceived(ULONG cbSize, BYTE* buffer) override + { + const hook::HookConfig config = hook::LoadConfig(); + + if (buffer == nullptr || cbSize == 0) + { + hook::Log(config.enableLogging, L"DVC-Trigger: Leere Nachricht empfangen; ignoriert."); + return S_OK; + } + + if (cbSize != hook::protocol::kStartMessageLength || + std::memcmp(buffer, hook::protocol::kStartMessage, hook::protocol::kStartMessageLength) != 0) + { + hook::Log(config.enableLogging, + L"DVC-Trigger: Unbekannte Nachricht empfangen; aus Sicherheitsgründen ignoriert."); + return S_OK; + } + + hook::Log(config.enableLogging, L"DVC-Trigger: START vom Terminalserver empfangen."); + + if (!config.enabled) + { + hook::Log(config.enableLogging, L"DVC-Trigger: Plugin-Funktion ist deaktiviert; Start ignoriert."); + return S_OK; + } + + if (!config.triggerEnabled) + { + hook::Log(config.enableLogging, L"DVC-Trigger: Server-Trigger ist in der Client-Konfiguration deaktiviert."); + return S_OK; + } + + // Der Terminalserver liefert absichtlich weder Pfad noch Argumente. + // Beides wird ausschließlich aus HKCU auf dem lokalen Client gelesen. + hook::ProcessLauncher launcher; + launcher.Start(config.triggerProgram, config.enableLogging, L"Server-Trigger-Programm"); + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnClose() override + { + const hook::HookConfig config = hook::LoadConfig(); + hook::Log(config.enableLogging, L"DVC-Trigger: Kanal geschlossen."); + return S_OK; + } + + private: + ~TriggerChannelCallback() = default; + LONG refCount_ = 1; + }; +} ScriptHookPlugin::ScriptHookPlugin() = default; -ScriptHookPlugin::~ScriptHookPlugin() = default; + +ScriptHookPlugin::~ScriptHookPlugin() +{ + ReleaseDvcObjects(); +} HRESULT STDMETHODCALLTYPE ScriptHookPlugin::QueryInterface(REFIID riid, void** ppvObject) { @@ -14,14 +114,22 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::QueryInterface(REFIID riid, void** p } *ppvObject = nullptr; + if (riid == __uuidof(IUnknown) || riid == __uuidof(IWTSPlugin)) { *ppvObject = static_cast(this); - AddRef(); - return S_OK; + } + else if (riid == __uuidof(IWTSListenerCallback)) + { + *ppvObject = static_cast(this); + } + else + { + return E_NOINTERFACE; } - return E_NOINTERFACE; + AddRef(); + return S_OK; } ULONG STDMETHODCALLTYPE ScriptHookPlugin::AddRef() @@ -41,9 +149,40 @@ ULONG STDMETHODCALLTYPE ScriptHookPlugin::Release() HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Initialize(IWTSVirtualChannelManager* channelManager) { - UNREFERENCED_PARAMETER(channelManager); const hook::HookConfig config = hook::LoadConfig(); hook::Log(config.enableLogging, L"IWTSPlugin::Initialize aufgerufen."); + + if (channelManager == nullptr) + { + hook::Log(config.enableLogging, L"DVC-Initialisierung fehlgeschlagen: channelManager ist NULL."); + return E_POINTER; + } + + ReleaseDvcObjects(); + + channelManager->AddRef(); + channelManager_ = channelManager; + + HRESULT hr = channelManager_->CreateListener( + const_cast(hook::protocol::kChannelName), + 0, + static_cast(this), + &listener_); + + if (FAILED(hr)) + { + hook::Log(config.enableLogging, + L"DVC-Listener konnte nicht erstellt werden. HRESULT=" + std::to_wstring(static_cast(hr)) + + L". Der automatische Start bei RDP-Verbindung bleibt trotzdem aktiv."); + ReleaseDvcObjects(); + + // Der DVC-Trigger ist eine optionale Zusatzfunktion. Ein nicht verfügbarer + // DVC darf den bestehenden Connected()/Disconnected()-Hook nicht deaktivieren. + return S_OK; + } + + hook::Log(config.enableLogging, + L"DVC-Listener für 'plandent::mstsc-script-hook' wurde registriert."); return S_OK; } @@ -54,12 +193,12 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Connected() if (!config.enabled || !config.startOnConnect) { - hook::Log(config.enableLogging, L"Start ist in der Konfiguration deaktiviert."); + hook::Log(config.enableLogging, L"Automatischer Start bei RDP-Verbindung ist deaktiviert."); return S_OK; } - // Nur Registry lesen + CreateProcess aufrufen. Es wird niemals auf das Script gewartet. - launcher_.Start(config); + // Nur Registry lesen + CreateProcess aufrufen. Es wird niemals auf das Programm gewartet. + connectLauncher_.Start(config.connectProgram, config.enableLogging, L"RDP-Verbindungsprogramm"); return S_OK; } @@ -71,7 +210,7 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Disconnected(DWORD disconnectCode) if (config.stopOnDisconnect) { - launcher_.Stop(config.enableLogging); + connectLauncher_.Stop(config.enableLogging, L"RDP-Verbindungsprogramm"); } return S_OK; } @@ -83,11 +222,64 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Terminated() if (config.stopOnDisconnect) { - launcher_.Stop(config.enableLogging); + connectLauncher_.Stop(config.enableLogging, L"RDP-Verbindungsprogramm"); } else { - launcher_.Detach(config.enableLogging); + connectLauncher_.Detach(config.enableLogging, L"RDP-Verbindungsprogramm"); } + + ReleaseDvcObjects(); return S_OK; } + +HRESULT STDMETHODCALLTYPE ScriptHookPlugin::OnNewChannelConnection( + IWTSVirtualChannel* channel, + BSTR data, + BOOL* accept, + IWTSVirtualChannelCallback** callback) +{ + UNREFERENCED_PARAMETER(data); + + const hook::HookConfig config = hook::LoadConfig(); + + if (accept == nullptr || callback == nullptr) + { + return E_POINTER; + } + + *accept = FALSE; + *callback = nullptr; + + if (channel == nullptr) + { + return E_POINTER; + } + + auto* channelCallback = new (std::nothrow) TriggerChannelCallback(); + if (channelCallback == nullptr) + { + hook::Log(config.enableLogging, L"DVC-Trigger: Callback konnte nicht angelegt werden."); + return E_OUTOFMEMORY; + } + + *callback = static_cast(channelCallback); + *accept = TRUE; + hook::Log(config.enableLogging, L"DVC-Trigger: Verbindung vom Terminalserver akzeptiert."); + return S_OK; +} + +void ScriptHookPlugin::ReleaseDvcObjects() +{ + if (listener_ != nullptr) + { + listener_->Release(); + listener_ = nullptr; + } + + if (channelManager_ != nullptr) + { + channelManager_->Release(); + channelManager_ = nullptr; + } +} diff --git a/Plugin/ScriptHookPlugin.h b/Plugin/ScriptHookPlugin.h index 450797d..4bce05f 100644 --- a/Plugin/ScriptHookPlugin.h +++ b/Plugin/ScriptHookPlugin.h @@ -4,7 +4,7 @@ #include #include -class ScriptHookPlugin final : public IWTSPlugin +class ScriptHookPlugin final : public IWTSPlugin, public IWTSListenerCallback { public: ScriptHookPlugin(); @@ -13,14 +13,25 @@ public: ULONG STDMETHODCALLTYPE AddRef() override; ULONG STDMETHODCALLTYPE Release() override; + // IWTSPlugin HRESULT STDMETHODCALLTYPE Initialize(IWTSVirtualChannelManager* channelManager) override; HRESULT STDMETHODCALLTYPE Connected() override; HRESULT STDMETHODCALLTYPE Disconnected(DWORD disconnectCode) override; HRESULT STDMETHODCALLTYPE Terminated() override; + // IWTSListenerCallback + HRESULT STDMETHODCALLTYPE OnNewChannelConnection( + IWTSVirtualChannel* channel, + BSTR data, + BOOL* accept, + IWTSVirtualChannelCallback** callback) override; + private: ~ScriptHookPlugin(); + void ReleaseDvcObjects(); LONG refCount_ = 1; - hook::ProcessLauncher launcher_; + hook::ProcessLauncher connectLauncher_; + IWTSVirtualChannelManager* channelManager_ = nullptr; + IWTSListener* listener_ = nullptr; }; diff --git a/README.md b/README.md index f5653a3..af2b31d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,64 @@ # Plandent MSTSC Script Hook -Kleines Windows-Projekt, das beim erfolgreichen Aufbau einer klassischen RDP-Verbindung mit `mstsc.exe` lokal ein konfiguriertes Script startet. +Windows-Projekt für den klassischen Microsoft Remote Desktop Connection Client (`mstsc.exe`). Das Client-Plugin kann lokale Programme auf zwei voneinander getrennten Wegen starten: + +1. **Automatisch beim erfolgreichen RDP-Verbindungsaufbau**. +2. **Gezielt durch `PlandentRdpClientTrigger.exe` innerhalb der Terminalserver-Sitzung**. + +Der Terminalserver überträgt dabei **keinen Programmpfad und keine Argumente**. Die Server-EXE sendet ausschließlich einen festen `START`-Befehl über einen RDP Dynamic Virtual Channel (DVC). Welches lokale Programm mit welchen Parametern gestartet wird, wird ausschließlich auf dem Client in der Registry konfiguriert. ## Komponenten -- **PlandentMstscScriptHook.dll** – native x64 DVC-Plugin-DLL für den Remote Desktop Connection Client. +### Client + +- **PlandentMstscScriptHook.dll** – native x64 DVC-Plugin-DLL, die von `mstsc.exe` geladen wird. - **PlandentMstscScriptHook.exe** – WinForms-Konfigurator/Installer. Die Release-EXE enthält die DLL als Resource und extrahiert sie bei der Installation. -## Verhalten +### Terminalserver -Die DLL implementiert `IWTSPlugin` und exportiert `VirtualChannelGetInstance`. +- **PlandentRdpClientTrigger.exe** – kleine native x64 Windows-EXE ohne GUI und ohne Parameter. Sie wird **innerhalb der betreffenden RDP-Benutzersitzung** ausgeführt und sendet den festen Start-Trigger an den Client dieser Sitzung. -- `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. +## Architektur -Der Plugin-Callback wartet **nicht** auf das Script. Es wird nur mit `CreateProcessW` gestartet und der Callback kehrt direkt zurück. +```text +LOKALER CLIENT TERMINALSERVER +================ ============== + +mstsc.exe + │ + └── PlandentMstscScriptHook.dll + │ + ├── Connected() + │ └── optional: lokales RDP-Verbindungsprogramm + │ + └── DVC Listener + plandent::mstsc-script-hook + ▲ + │ fester Befehl: + │ PLANDENT_MSTSC_HOOK/1 START + │ + └──────────────────── PlandentRdpClientTrigger.exe + │ + └── läuft in der RDP-Sitzung + +Nach START liest die DLL ausschließlich lokal: +HKCU\Software\Plandent\MstscScriptHook + TriggerProgramPath + TriggerArguments + TriggerWorkingDirectory +``` + +## Sicherheit des Triggers + +Die Server-Komponente ist absichtlich minimal gehalten: + +- keine Kommandozeilenparameter für das Client-Programm, +- kein Pfad wird vom Terminalserver übertragen, +- keine beliebigen Befehle werden akzeptiert, +- ausschließlich die exakte Protokollnachricht `PLANDENT_MSTSC_HOOK/1 START` wird verarbeitet, +- Programmpfad und Argumente stammen nur aus der lokalen HKCU-Konfiguration des angemeldeten Client-Benutzers. + +Damit kann `PlandentRdpClientTrigger.exe` nur den vorher auf dem Client festgelegten Start auslösen. ## Registry @@ -31,30 +73,110 @@ 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 + Enabled REG_DWORD 1 + EnableLogging REG_DWORD 1 + + ; Automatischer Start bei RDP-Verbindung + 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 + + ; Separates Programm für den Terminalserver-Trigger + TriggerEnabled REG_DWORD 1 + TriggerProgramPath REG_EXPAND_SZ C:\Program Files\Plandent\ClientTool.exe + TriggerArguments REG_SZ --receive --silent + TriggerWorkingDirectory REG_EXPAND_SZ C:\Program Files\Plandent + TriggerHidden REG_DWORD 1 + TriggerPreventDuplicates REG_DWORD 1 ``` -## Unterstützte Starttypen +Die bisherigen Registry-Werte für den automatischen RDP-Start bleiben kompatibel. + +## Unterstützte lokale Starttypen + +Für beide Startarten werden unterstützt: - `.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. +Bei aktivierter Option **Unsichtbar starten** werden Konsolenprogramme ohne sichtbares Konsolenfenster gestartet. ## 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. +Für beide Programme existiert eine getrennt konfigurierbare Option **Mehrfachstart verhindern**. -Der Schutz ist bewusst `Local\\...`, also auf die lokale Windows-Sitzung begrenzt. +Der Schutz wird aus dem expandierten Programmpfad abgeleitet und verwendet einen Named Mutex unter: + +```text +Local\Plandent.MstscScriptHook. +``` + +Das Mutex-Handle wird an den gestarteten Root-Prozess vererbt. Solange dieser Prozess läuft, wird ein erneuter Start desselben Pfades verhindert. + +## Dynamic Virtual Channel + +Das Client-Plugin registriert in `IWTSPlugin::Initialize()` den DVC-Listener: + +```text +plandent::mstsc-script-hook +``` + +Wenn `PlandentRdpClientTrigger.exe` innerhalb der RDP-Sitzung läuft, öffnet sie mit `WTSVirtualChannelOpenEx(WTS_CURRENT_SESSION, ..., WTS_CHANNEL_OPTION_DYNAMIC)` genau diesen Kanal und sendet den festen Startbefehl. + +Auf dem Client nimmt `IWTSListenerCallback::OnNewChannelConnection()` den Kanal an. `IWTSVirtualChannelCallback::OnDataReceived()` akzeptiert ausschließlich die definierte `START`-Nachricht und startet anschließend das lokal konfigurierte Trigger-Programm. + +## Terminalserver-Trigger verwenden + +`PlandentRdpClientTrigger.exe` benötigt keine Argumente: + +```powershell +.\PlandentRdpClientTrigger.exe +``` + +Sie sollte von einem Prozess innerhalb der Benutzer-RDP-Sitzung gestartet werden, deren Client angesprochen werden soll. + +Die EXE öffnet **keinen Netzwerkport**, benötigt keine zusätzliche Firewallfreigabe und kommuniziert ausschließlich über den bestehenden RDP-DVC. + +### Exitcodes + +```text +0 Trigger wurde an den DVC geschrieben +10 DVC konnte nicht geöffnet werden +11 Schreiben auf den DVC fehlgeschlagen +12 Trigger-Nachricht wurde nicht vollständig geschrieben +``` + +Ein Exitcode `10` tritt beispielsweise auf, wenn die EXE nicht innerhalb einer passenden RDP-Sitzung läuft, das Client-Plugin nicht geladen wurde oder der DVC nicht verfügbar ist. + +## Client-Konfigurator + +Die GUI enthält jetzt zwei getrennte Bereiche: + +### Automatischer Start bei RDP-Verbindung + +- Programm/Script +- Argumente +- Arbeitsverzeichnis +- Bei erfolgreicher RDP-Verbindung starten +- Bei RDP-Trennung beenden +- Unsichtbar starten +- Mehrfachstart verhindern +- lokaler Test + +### Programmstart durch Terminalserver + +- Trigger aktivieren/deaktivieren +- separates Programm/Script +- separate Argumente +- separates Arbeitsverzeichnis +- Unsichtbar starten +- Mehrfachstart verhindern +- lokaler Test ## Logging @@ -64,16 +186,28 @@ Bei aktiviertem Logging: %LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log ``` -Die GUI enthält einen Button **Log öffnen**. +Dort werden sowohl der normale RDP-Lifecycle als auch DVC-Verbindungen und Triggerstarts protokolliert. ## Voraussetzungen zum Bauen -- Windows 10/11 x64 -- Visual Studio 2022 oder Visual Studio Build Tools 2022 +- Windows 10/11 x64 als Entwicklungsrechner +- Visual Studio 2022 oder entsprechende Build Tools - Workload **Desktopentwicklung mit C++** - Windows 10/11 SDK - .NET 8 SDK +## Debug-Build in Visual Studio + +`Configurator.csproj` ist für **Debug** jetzt absichtlich framework-dependent konfiguriert. Dadurch werden beim normalen F5-Build keine `Microsoft.*.Runtime.win-x64`-Runtime-Pakete benötigt. + +Als Startprojekt in Visual Studio: + +```text +PlandentMstscScriptHook.Configurator +``` + +Die Plugin-DLL wird über die Projektabhängigkeit zuerst gebaut und anschließend als Resource in den Configurator eingebettet. + ## Release bauen PowerShell im Projektverzeichnis: @@ -85,33 +219,65 @@ PowerShell im Projektverzeichnis: Ergebnis: ```text -dist\PlandentMstscScriptHook.exe +dist\ +├── PlandentMstscScriptHook.exe +└── PlandentRdpClientTrigger.exe ``` -Die veröffentlichte EXE ist x64, self-contained und enthält die native Plugin-DLL als Resource. +- `PlandentMstscScriptHook.exe` → auf dem **lokalen RDP-Client** verwenden. +- `PlandentRdpClientTrigger.exe` → auf den **Terminalserver** kopieren. -## Installation +Die Client-EXE ist im Release x64, self-contained und Single-File. Die native Plugin-DLL ist darin eingebettet. + +## Installation auf dem Client 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**. +2. Gewünschten automatischen RDP-Start konfigurieren oder deaktivieren. +3. Optional unter **Programmstart durch Terminalserver** das lokale Trigger-Programm einschließlich Argumenten konfigurieren. +4. **Start durch Terminalserver-Trigger erlauben** aktivieren. +5. Optional beide Programme mit den Testbuttons lokal testen. 6. **Plugin installieren** anklicken. -7. Alle bereits laufenden `mstsc.exe`-Instanzen schließen. -8. MSTSC neu starten und eine RDP-Verbindung herstellen. +7. Alle bereits laufenden `mstsc.exe`-Instanzen vollständig schließen. +8. MSTSC neu starten und die RDP-Verbindung herstellen. -Die Installation erfolgt nur unter `HKCU` und `%LOCALAPPDATA%`; dafür sind normalerweise keine Administratorrechte nötig. +Die Client-Installation erfolgt unter `HKCU` und `%LOCALAPPDATA%`; dafür sind normalerweise keine Administratorrechte nötig. -## Deinstallation +## Installation auf dem Terminalserver -**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. +Es ist keine Registrierung erforderlich. Lediglich: -Die Script-Konfiguration unter `HKCU\Software\Plandent\MstscScriptHook` bleibt absichtlich erhalten. +```text +PlandentRdpClientTrigger.exe +``` + +an einen geeigneten Ort kopieren und von der Anwendung ausführen, die den lokalen Start anfordern soll. + +Beispiel aus einer Batchdatei innerhalb der RDP-Sitzung: + +```bat +C:\Tools\PlandentRdpClientTrigger.exe +``` + +Beispiel PowerShell mit Exitcode-Prüfung: + +```powershell +& 'C:\Tools\PlandentRdpClientTrigger.exe' +if ($LASTEXITCODE -ne 0) { + Write-Warning "Client-Trigger fehlgeschlagen. ExitCode: $LASTEXITCODE" +} +``` + +## Deinstallation auf dem Client + +**Plugin entfernen** löscht den MSTSC-Registryeintrag und die installierte DLL. Ist die DLL noch in einer laufenden `mstsc.exe` geladen, wird die Registrierung entfernt und die DLL kann nach dem Schließen aller MSTSC-Instanzen erneut entfernt werden. + +Die eigentliche Programmkonfiguration 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. +- Das Plugin ist für den klassischen Microsoft-RDC-Pluginmechanismus ausgelegt. Andere RDP-Clients müssen diesen Mechanismus unterstützen. +- Der Server-Trigger muss innerhalb einer aktiven RDP-Sitzung ausgeführt werden, wenn `WTS_CURRENT_SESSION` verwendet wird. +- Das lokale Programm läuft mit den Rechten des Benutzers, der `mstsc.exe` auf dem Client gestartet hat. +- Der Server kann über dieses Protokoll **keine** beliebigen Programme oder Parameter vorgeben. +- `StopOnDisconnect` gilt ausschließlich für das automatische RDP-Verbindungsprogramm, nicht für das per Server ausgelöste Programm. +- AppLocker/WDAC oder andere Application-Control-Richtlinien können das Laden einer nicht signierten DLL bzw. den Programmstart blockieren. diff --git a/ServerTrigger/Main.cpp b/ServerTrigger/Main.cpp new file mode 100644 index 0000000..90eb9d8 --- /dev/null +++ b/ServerTrigger/Main.cpp @@ -0,0 +1,65 @@ +#include "../Shared/TriggerProtocol.h" + +#include +#include + +#include +#include + +namespace +{ + enum class ExitCode : int + { + Success = 0, + ChannelOpenFailed = 10, + WriteFailed = 11, + PartialWrite = 12 + }; + + void DebugError(const wchar_t* step, DWORD error) + { + wchar_t buffer[256]{}; + swprintf_s(buffer, _countof(buffer), L"PlandentRdpClientTrigger: %s fehlgeschlagen, Win32=%lu\n", step, error); + OutputDebugStringW(buffer); + } +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) +{ + // WTS_CURRENT_SESSION stellt sicher, dass ausschließlich der Client der + // RDP-Sitzung angesprochen wird, in der diese EXE ausgeführt wird. + HANDLE channel = WTSVirtualChannelOpenEx( + WTS_CURRENT_SESSION, + const_cast(hook::protocol::kChannelName), + WTS_CHANNEL_OPTION_DYNAMIC | WTS_CHANNEL_OPTION_DYNAMIC_PRI_MED); + + if (channel == nullptr) + { + DebugError(L"WTSVirtualChannelOpenEx", GetLastError()); + return static_cast(ExitCode::ChannelOpenFailed); + } + + ULONG bytesWritten = 0; + BOOL written = WTSVirtualChannelWrite( + channel, + const_cast(hook::protocol::kStartMessage), + static_cast(hook::protocol::kStartMessageLength), + &bytesWritten); + + DWORD writeError = written ? ERROR_SUCCESS : GetLastError(); + WTSVirtualChannelClose(channel); + + if (!written) + { + DebugError(L"WTSVirtualChannelWrite", writeError); + return static_cast(ExitCode::WriteFailed); + } + + if (bytesWritten != static_cast(hook::protocol::kStartMessageLength)) + { + OutputDebugStringW(L"PlandentRdpClientTrigger: Trigger-Nachricht wurde nur teilweise geschrieben.\n"); + return static_cast(ExitCode::PartialWrite); + } + + return static_cast(ExitCode::Success); +} diff --git a/ServerTrigger/PlandentRdpClientTrigger.vcxproj b/ServerTrigger/PlandentRdpClientTrigger.vcxproj new file mode 100644 index 0000000..b79b2f3 --- /dev/null +++ b/ServerTrigger/PlandentRdpClientTrigger.vcxproj @@ -0,0 +1,91 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE} + PlandentRdpClientTrigger + 10.0 + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + $(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\ + $(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\ServerTrigger\ + PlandentRdpClientTrigger + + + + Level4 + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + MultiThreadedDebug + + + Windows + true + Wtsapi32.lib;%(AdditionalDependencies) + + + + + Level4 + true + true + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + MultiThreaded + + + Windows + true + true + false + Wtsapi32.lib;%(AdditionalDependencies) + + + + + + + + + + + diff --git a/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters b/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters new file mode 100644 index 0000000..5d0cf2d --- /dev/null +++ b/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters @@ -0,0 +1,13 @@ + + + + {D6213E4F-B945-4B58-A331-B988E3EC325B}cpp;c + {81A97E24-D824-427B-B039-F2CBE560B587}h;hpp + + + Source Files + + + Header Files + + diff --git a/Shared/TriggerProtocol.h b/Shared/TriggerProtocol.h new file mode 100644 index 0000000..cb2acd8 --- /dev/null +++ b/Shared/TriggerProtocol.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace hook::protocol +{ + // Der Dynamic-Virtual-Channel-Name muss auf Client und Terminalserver identisch sein. + // DVC-Namen dürfen laut WTSVirtualChannelOpenEx bis MAX_PATH lang sein. + inline constexpr char kChannelName[] = "plandent::mstsc-script-hook"; + + // Absichtlich ein festes Protokollkommando ohne Pfad/Argumente. + // Der Terminalserver kann damit nur den lokal vorkonfigurierten Start anfordern. + inline constexpr char kStartMessage[] = "PLANDENT_MSTSC_HOOK/1 START"; + inline constexpr std::size_t kStartMessageLength = sizeof(kStartMessage) - 1; +} diff --git a/build-release.ps1 b/build-release.ps1 index d494cbb..0b5dc48 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -8,6 +8,7 @@ param( $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $PSCommandPath $PluginProject = Join-Path $Root 'Plugin\PlandentMstscScriptHook.vcxproj' +$ServerTriggerProject = Join-Path $Root 'ServerTrigger\PlandentRdpClientTrigger.vcxproj' $ConfiguratorProject = Join-Path $Root 'Configurator\Configurator.csproj' $Dist = Join-Path $Root 'dist' $Artifacts = Join-Path $Root 'artifacts' @@ -35,7 +36,8 @@ if ($Clean) { 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 + +Write-Host "[1/3] 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)." @@ -46,7 +48,19 @@ 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 +Write-Host "[2/3] Terminalserver-Trigger bauen ($Configuration|x64)..." -ForegroundColor Cyan +& $MSBuild $ServerTriggerProject /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64' +if ($LASTEXITCODE -ne 0) { + throw "Server-Trigger Build fehlgeschlagen (ExitCode $LASTEXITCODE)." +} + +$ServerTriggerExe = Join-Path $Artifacts "x64\$Configuration\PlandentRdpClientTrigger.exe" +if (-not (Test-Path $ServerTriggerExe)) { + throw "Erwartete Server-Trigger-EXE wurde nicht erzeugt: $ServerTriggerExe" +} +Copy-Item $ServerTriggerExe (Join-Path $Dist 'PlandentRdpClientTrigger.exe') -Force + +Write-Host "[3/3] Client-Konfigurations-EXE als x64 Single-File veröffentlichen..." -ForegroundColor Cyan & dotnet publish $ConfiguratorProject ` -c $Configuration ` -r win-x64 ` @@ -60,13 +74,15 @@ 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" +$ConfiguratorExe = Join-Path $Dist 'PlandentMstscScriptHook.exe' +if (-not (Test-Path $ConfiguratorExe)) { + throw "Erwartete Konfigurations-EXE wurde nicht erzeugt: $ConfiguratorExe" } Write-Host '' Write-Host 'Build erfolgreich:' -ForegroundColor Green -Write-Host " $Exe" +Write-Host " Client: $ConfiguratorExe" +Write-Host " Server: $(Join-Path $Dist 'PlandentRdpClientTrigger.exe')" Write-Host '' -Write-Host 'Diese EXE enthält die native DLL als eingebettete Resource.' +Write-Host 'Die Client-EXE enthält die native MSTSC-Plugin-DLL als eingebettete Resource.' +Write-Host 'Die Server-EXE sendet ausschließlich den festen START-Trigger über den RDP-DVC.' diff --git a/examples/README.md b/examples/README.md index f6171f6..be556fd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,13 +1,24 @@ -# RemoteVDDS-Beispiel +# Beispiele -Für `RemoteVDDS_Receiver_TS.bat` empfiehlt sich: +## RemoteVDDS-Preset.reg -- 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 +Beispielwerte für den bisherigen automatischen RemoteVDDS-Start: -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. +```text +ScriptPath = C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat +Arguments = MIN +StartOnConnect = 1 +Hidden = 1 +``` + +Der neue Terminalserver-Trigger ist in diesem Preset absichtlich deaktiviert. Das Trigger-Programm sollte über den Client-Konfigurator ausgewählt werden, damit Pfad, Argumente und Arbeitsverzeichnis zum jeweiligen Rechner passen. + +## Terminalserver + +`PlandentRdpClientTrigger.exe` benötigt keine Argumente. Ausführen innerhalb der gewünschten RDP-Benutzersitzung: + +```powershell +.\PlandentRdpClientTrigger.exe +``` + +Die EXE sendet nur das feste `START`-Kommando. Das tatsächlich zu startende Programm wird ausschließlich auf dem Client konfiguriert. diff --git a/examples/RemoteVDDS-Preset.reg b/examples/RemoteVDDS-Preset.reg index 13f17ca..3f3f803 100644 --- a/examples/RemoteVDDS-Preset.reg +++ b/examples/RemoteVDDS-Preset.reg @@ -2,11 +2,21 @@ Windows Registry Editor Version 5.00 [HKEY_CURRENT_USER\Software\Plandent\MstscScriptHook] "Enabled"=dword:00000001 -"ScriptPath"="C:\\RemoteVDDS\\RemoteVDDS_Receiver_TS.bat" +"EnableLogging"=dword:00000001 + +; Automatischer RemoteVDDS-Receiver bei RDP-Verbindung +"ScriptPath"=hex(2):43,00,3a,00,5c,00,52,00,65,00,6d,00,6f,00,74,00,65,00,56,00,44,00,44,00,53,00,5c,00,52,00,65,00,6d,00,6f,00,74,00,65,00,56,00,44,00,44,00,53,00,5f,00,52,00,65,00,63,00,65,00,69,00,76,00,65,00,72,00,5f,00,54,00,53,00,2e,00,62,00,61,00,74,00,00,00 "Arguments"="MIN" -"WorkingDirectory"="C:\\RemoteVDDS" +"WorkingDirectory"=hex(2):43,00,3a,00,5c,00,52,00,65,00,6d,00,6f,00,74,00,65,00,56,00,44,00,44,00,53,00,00,00 "StartOnConnect"=dword:00000001 "StopOnDisconnect"=dword:00000000 "Hidden"=dword:00000001 "PreventDuplicates"=dword:00000001 -"EnableLogging"=dword:00000001 + +; Server-Trigger standardmäßig deaktiviert. Über die GUI aktivieren und Pfad/Argumente setzen. +"TriggerEnabled"=dword:00000000 +"TriggerProgramPath"="" +"TriggerArguments"="" +"TriggerWorkingDirectory"="" +"TriggerHidden"=dword:00000001 +"TriggerPreventDuplicates"=dword:00000001