feat: initiale Version 0.1.0 des MSTSC Script Hooks

- Native x64 MSTSC-DVC-Erweiterung für lokalen Programmstart
- Automatischer Script-/Programmstart bei RDP-Verbindungsaufbau
- Separater Programmstart über Trigger innerhalb der RDP-Sitzung
- Bidirektionaler DVC zur Ermittlung des lokalen Client-Hostnamens
- SIOMIN-Trigger zur Anbindung lokaler Sidexis-Installationen
- SIOMIN-Datensatzlängen und Hostname-Felder bytegenau angepasst
- Temporäre SIOMIN-Einträge nach erfolgreicher Übernahme bereinigt
- Native Win32-Konfigurationsoberfläche ohne .NET-Abhängigkeit
- Mehrfachstartschutz und optionaler unsichtbarer Programmstart
- Debug- und Plugin-Logging ergänzt
- RemoteVDDS-Anwendungsfall integriert
- Installer/Registrierung des MSTSC-Plugins über Client-Konfigurator
- Techniker-, Entwickler- und Projektdokumentation ergänzt
- Proprietäre Lizenz für Copyright Patrick Gniza hinzugefügt
This commit is contained in:
2026-08-11 16:32:33 +02:00
parent ee88f1ff30
commit 4c51989600
37 changed files with 4885 additions and 1475 deletions
+1
View File
@@ -3,5 +3,6 @@
**/obj/ **/obj/
artifacts/ artifacts/
dist/ dist/
Configurator/Generated/
*.user *.user
*.suo *.suo
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
namespace config
{
inline constexpr wchar_t kConfigKey[] = L"Software\\Plandent\\MstscScriptHook";
inline constexpr wchar_t kAddInKey[] = L"Software\\Microsoft\\Terminal Server Client\\Default\\AddIns\\PlandentMstscScriptHook";
struct HookConfig
{
bool enabled = true;
bool enableLogging = true;
std::wstring scriptPath;
std::wstring arguments;
std::wstring workingDirectory;
bool startOnConnect = true;
bool stopOnDisconnect = false;
bool hidden = true;
bool preventDuplicates = true;
bool triggerEnabled = false;
std::wstring triggerProgramPath;
std::wstring triggerArguments;
std::wstring triggerWorkingDirectory;
bool triggerHidden = true;
bool triggerPreventDuplicates = true;
};
HookConfig Load();
bool Save(const HookConfig& value, std::wstring& error);
std::wstring GetRegisteredPluginPath();
bool RegisterPlugin(const std::wstring& dllPath, std::wstring& error);
bool UnregisterPlugin(std::wstring& error);
std::wstring ExpandEnvironment(const std::wstring& value);
}
-39
View File
@@ -1,39 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>PlandentMstscScriptHook</AssemblyName>
<RootNamespace>Plandent.MstscScriptHook.Configurator</RootNamespace>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<PluginPayloadPath>$(MSBuildProjectDirectory)\..\artifacts\x64\$(Configuration)\PlandentMstscScriptHook.dll</PluginPayloadPath>
</PropertyGroup>
<!-- Beim normalen Visual-Studio-Debugging wird die lokal installierte .NET-8-Runtime benutzt.
Dadurch werden keine win-x64 Runtime-Packs von NuGet benötigt. -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<DebugType>portable</DebugType>
</PropertyGroup>
<!-- Nur das Release-Publish erzeugt die verteilbare self-contained Single-File-EXE. -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="$(PluginPayloadPath)" LogicalName="PlandentMstscScriptHook.Native.PlandentMstscScriptHook.dll" />
</ItemGroup>
<Target Name="CheckNativePlugin" BeforeTargets="CreateManifestResourceNames">
<Error Condition="!Exists('$(PluginPayloadPath)')" Text="Native Plugin-DLL fehlt: $(PluginPayloadPath). Zuerst Plugin bauen oder build-release.ps1 verwenden." />
</Target>
</Project>
+3
View File
@@ -0,0 +1,3 @@
#include "resource.h"
IDR_PLUGIN_DLL RCDATA "Generated\\PlandentMstscScriptHook.dll"
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}</ProjectGuid>
<RootNamespace>PlandentMstscScriptHookConfigurator</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings" />
<ImportGroup Label="Shared" />
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<OutDir>$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\Configurator\</IntDir>
<TargetName>PlandentMstscScriptHook</TargetName>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
</ClCompile>
<ResourceCompile>
<PreprocessorDefinitions>UNICODE;_UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ResourceCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>Advapi32.lib;Shell32.lib;Comdlg32.lib;Ole32.lib;Comctl32.lib;User32.lib;Gdi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
<PreBuildEvent>
<Command>if not exist "$(ProjectDir)Generated" mkdir "$(ProjectDir)Generated"
if not exist "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" (echo Native Plugin-DLL fehlt. Projektabhaengigkeit pruefen. &amp; exit /b 1)
copy /Y "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" "$(ProjectDir)Generated\PlandentMstscScriptHook.dll" &gt;nul</Command>
</PreBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
</ClCompile>
<ResourceCompile>
<PreprocessorDefinitions>UNICODE;_UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ResourceCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>false</GenerateDebugInformation>
<AdditionalDependencies>Advapi32.lib;Shell32.lib;Comdlg32.lib;Ole32.lib;Comctl32.lib;User32.lib;Gdi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
<PreBuildEvent>
<Command>if not exist "$(ProjectDir)Generated" mkdir "$(ProjectDir)Generated"
if not exist "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" (echo Native Plugin-DLL fehlt. Projektabhaengigkeit pruefen. &amp; exit /b 1)
copy /Y "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" "$(ProjectDir)Generated\PlandentMstscScriptHook.dll" &gt;nul</Command>
</PreBuildEvent>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="Main.cpp" />
<ClCompile Include="PluginInstaller.cpp" />
<ClCompile Include="RegistryConfig.cpp" />
<ClCompile Include="TestLauncher.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="Config.h" />
<ClInclude Include="PluginInstaller.h" />
<ClInclude Include="resource.h" />
<ClInclude Include="TestLauncher.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Configurator.rc" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Plugin\PlandentMstscScriptHook.vcxproj">
<Project>{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}</Project>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<LinkLibraryDependencies>false</LinkLibraryDependencies>
</ProjectReference>
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets" />
</Project>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{D7D3B9B1-4B80-4A4B-888D-E7CA10A76FC5}</UniqueIdentifier>
<Extensions>cpp;c;cxx;cc</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{6C56EC9E-3B39-44B2-B913-74A798B7EA8F}</UniqueIdentifier>
<Extensions>h;hpp;hxx</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{37447730-171A-4380-A30C-CA33A0A2FF38}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="Main.cpp"><Filter>Source Files</Filter></ClCompile>
<ClCompile Include="PluginInstaller.cpp"><Filter>Source Files</Filter></ClCompile>
<ClCompile Include="RegistryConfig.cpp"><Filter>Source Files</Filter></ClCompile>
<ClCompile Include="TestLauncher.cpp"><Filter>Source Files</Filter></ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="Config.h"><Filter>Header Files</Filter></ClInclude>
<ClInclude Include="PluginInstaller.h"><Filter>Header Files</Filter></ClInclude>
<ClInclude Include="resource.h"><Filter>Header Files</Filter></ClInclude>
<ClInclude Include="TestLauncher.h"><Filter>Header Files</Filter></ClInclude>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Configurator.rc"><Filter>Resource Files</Filter></ResourceCompile>
</ItemGroup>
</Project>
-25
View File
@@ -1,25 +0,0 @@
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;
public bool StartOnConnect { get; set; } = true;
public bool StopOnDisconnect { get; set; }
public bool Hidden { get; set; } = true;
public bool PreventDuplicates { 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;
}
+925
View File
@@ -0,0 +1,925 @@
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <commctrl.h>
#include <commdlg.h>
#include <shobjidl.h>
#include <shellapi.h>
#include "Config.h"
#include "PluginInstaller.h"
#include "TestLauncher.h"
#include <algorithm>
#include <cwchar>
#include <cwctype>
#include <string>
#include <vector>
#pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")
namespace
{
constexpr wchar_t kWindowClass[] = L"PlandentMstscScriptHookConfigurator";
constexpr wchar_t kTitle[] = L"Plandent MSTSC Script Hook";
enum ControlId : int
{
IDC_ENABLED = 1001,
IDC_LOGGING,
IDC_CONN_PATH,
IDC_CONN_BROWSE,
IDC_CONN_ARGS,
IDC_CONN_WORKDIR,
IDC_CONN_WORK_BROWSE,
IDC_CONN_START,
IDC_CONN_STOP,
IDC_CONN_HIDDEN,
IDC_CONN_DUP,
IDC_CONN_TEST,
IDC_CONN_TEST_STOP,
IDC_TRIGGER_PATH,
IDC_TRIGGER_BROWSE,
IDC_TRIGGER_ARGS,
IDC_TRIGGER_WORKDIR,
IDC_TRIGGER_WORK_BROWSE,
IDC_TRIGGER_ENABLED,
IDC_TRIGGER_HIDDEN,
IDC_TRIGGER_DUP,
IDC_TRIGGER_TEST,
IDC_TRIGGER_TEST_STOP,
IDC_SAVE,
IDC_INSTALL,
IDC_REMOVE,
IDC_LOG_OPEN,
IDC_TIMER = 2001
};
struct AppState
{
HWND window = nullptr;
HFONT normalFont = nullptr;
HFONT titleFont = nullptr;
HWND title = nullptr;
HWND description = nullptr;
HWND generalGroup = nullptr;
HWND enabled = nullptr;
HWND logging = nullptr;
HWND connectGroup = nullptr;
HWND connectDescription = nullptr;
HWND connectPathLabel = nullptr;
HWND connectPath = nullptr;
HWND connectBrowse = nullptr;
HWND connectArgsLabel = nullptr;
HWND connectArgs = nullptr;
HWND connectWorkLabel = nullptr;
HWND connectWork = nullptr;
HWND connectWorkBrowse = nullptr;
HWND connectStart = nullptr;
HWND connectStop = nullptr;
HWND connectHidden = nullptr;
HWND connectDup = nullptr;
HWND connectTest = nullptr;
HWND connectTestStop = nullptr;
HWND triggerGroup = nullptr;
HWND triggerDescription = nullptr;
HWND triggerPathLabel = nullptr;
HWND triggerPath = nullptr;
HWND triggerBrowse = nullptr;
HWND triggerArgsLabel = nullptr;
HWND triggerArgs = nullptr;
HWND triggerWorkLabel = nullptr;
HWND triggerWork = nullptr;
HWND triggerWorkBrowse = nullptr;
HWND triggerEnabled = nullptr;
HWND triggerHidden = nullptr;
HWND triggerDup = nullptr;
HWND triggerTest = nullptr;
HWND triggerTestStop = nullptr;
HWND status = nullptr;
HWND hint = nullptr;
HWND save = nullptr;
HWND install = nullptr;
HWND remove = nullptr;
HWND logOpen = nullptr;
testlaunch::TestProcess connectTestProcess;
testlaunch::TestProcess triggerTestProcess;
};
AppState g;
void SetFont(HWND control, HFONT font)
{
SendMessageW(control, WM_SETFONT, reinterpret_cast<WPARAM>(font), TRUE);
}
HWND MakeControl(
const wchar_t* className,
const wchar_t* text,
DWORD style,
DWORD exStyle = 0,
int id = 0)
{
HWND handle = CreateWindowExW(
exStyle,
className,
text,
WS_CHILD | WS_VISIBLE | style,
0,
0,
10,
10,
g.window,
reinterpret_cast<HMENU>(static_cast<INT_PTR>(id)),
GetModuleHandleW(nullptr),
nullptr);
if (handle != nullptr)
{
SetFont(handle, g.normalFont);
}
return handle;
}
HWND MakeStatic(const wchar_t* text, DWORD style = SS_LEFT)
{
return MakeControl(L"STATIC", text, style);
}
HWND MakeButton(const wchar_t* text, int id)
{
return MakeControl(L"BUTTON", text, BS_PUSHBUTTON | WS_TABSTOP, 0, id);
}
HWND MakeCheck(const wchar_t* text, int id)
{
return MakeControl(L"BUTTON", text, BS_AUTOCHECKBOX | WS_TABSTOP, 0, id);
}
HWND MakeEdit(int id)
{
return MakeControl(L"EDIT", L"", ES_AUTOHSCROLL | WS_TABSTOP, WS_EX_CLIENTEDGE, id);
}
std::wstring Text(HWND control)
{
int len = GetWindowTextLengthW(control);
std::vector<wchar_t> buffer(static_cast<size_t>(len) + 1, L'\0');
GetWindowTextW(control, buffer.data(), static_cast<int>(buffer.size()));
return std::wstring(buffer.data());
}
void SetText(HWND control, const std::wstring& value)
{
SetWindowTextW(control, value.c_str());
}
bool Checked(HWND control)
{
return SendMessageW(control, BM_GETCHECK, 0, 0) == BST_CHECKED;
}
void SetChecked(HWND control, bool checked)
{
SendMessageW(control, BM_SETCHECK, checked ? BST_CHECKED : BST_UNCHECKED, 0);
}
void Move(HWND control, int x, int y, int width, int height)
{
MoveWindow(control, x, y, std::max(width, 1), height, TRUE);
}
std::wstring ParentDirectory(const std::wstring& path)
{
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 FileName(const std::wstring& path)
{
size_t slash = path.find_last_of(L"\\/");
return slash == std::wstring::npos ? path : path.substr(slash + 1);
}
std::wstring Lower(std::wstring value)
{
std::transform(value.begin(), value.end(), value.begin(), [](wchar_t c) { return static_cast<wchar_t>(std::towlower(c)); });
return value;
}
std::wstring Trim(const std::wstring& value)
{
size_t first = 0;
while (first < value.size() && std::iswspace(value[first]))
{
++first;
}
size_t last = value.size();
while (last > first && std::iswspace(value[last - 1]))
{
--last;
}
return value.substr(first, last - first);
}
bool FileExists(const std::wstring& path)
{
DWORD attr = GetFileAttributesW(path.c_str());
return attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY) == 0;
}
bool DirectoryExists(const std::wstring& path)
{
DWORD attr = GetFileAttributesW(path.c_str());
return attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY) != 0;
}
bool SupportedExtension(const std::wstring& path)
{
size_t dot = path.find_last_of(L'.');
if (dot == std::wstring::npos)
{
return false;
}
std::wstring ext = Lower(path.substr(dot));
return ext == L".exe" || ext == L".bat" || ext == L".cmd" || ext == L".ps1";
}
void ErrorBox(const std::wstring& text, const wchar_t* caption = L"MSTSC Script Hook")
{
MessageBoxW(g.window, text.c_str(), caption, MB_OK | MB_ICONERROR);
}
void InfoBox(const std::wstring& text, const wchar_t* caption = L"MSTSC Script Hook")
{
MessageBoxW(g.window, text.c_str(), caption, MB_OK | MB_ICONINFORMATION);
}
void WarningBox(const std::wstring& text, const wchar_t* caption = L"MSTSC Script Hook")
{
MessageBoxW(g.window, text.c_str(), caption, MB_OK | MB_ICONWARNING);
}
void CreateControls()
{
g.title = MakeStatic(L"MSTSC Script Hook");
SetFont(g.title, g.titleFont);
g.description = MakeStatic(
L"Lokale Programme können automatisch beim RDP-Verbindungsaufbau oder gezielt durch eine Anwendung innerhalb der Terminalserver-Sitzung gestartet werden.");
g.generalGroup = MakeControl(L"BUTTON", L"Allgemein", BS_GROUPBOX);
g.enabled = MakeCheck(L"Plugin-Funktion aktiviert", IDC_ENABLED);
g.logging = MakeCheck(L"Plugin-Logging aktivieren", IDC_LOGGING);
g.connectGroup = MakeControl(L"BUTTON", L"Automatischer Start bei RDP-Verbindung", BS_GROUPBOX);
g.connectDescription = MakeStatic(
L"Optionales lokales Programm/Script, das direkt nach erfolgreichem Aufbau der klassischen MSTSC-Verbindung gestartet wird.");
g.connectPathLabel = MakeStatic(L"Programm/Script:");
g.connectPath = MakeEdit(IDC_CONN_PATH);
g.connectBrowse = MakeButton(L"...", IDC_CONN_BROWSE);
g.connectArgsLabel = MakeStatic(L"Argumente:");
g.connectArgs = MakeEdit(IDC_CONN_ARGS);
g.connectWorkLabel = MakeStatic(L"Arbeitsverzeichnis:");
g.connectWork = MakeEdit(IDC_CONN_WORKDIR);
g.connectWorkBrowse = MakeButton(L"...", IDC_CONN_WORK_BROWSE);
g.connectStart = MakeCheck(L"Bei erfolgreicher RDP-Verbindung starten", IDC_CONN_START);
g.connectStop = MakeCheck(L"Bei RDP-Trennung beenden", IDC_CONN_STOP);
g.connectHidden = MakeCheck(L"Unsichtbar starten", IDC_CONN_HIDDEN);
g.connectDup = MakeCheck(L"Mehrfachstart verhindern", IDC_CONN_DUP);
g.connectTest = MakeButton(L"Verbindungs-Programm testen", IDC_CONN_TEST);
g.connectTestStop = MakeButton(L"Test beenden", IDC_CONN_TEST_STOP);
g.triggerGroup = MakeControl(L"BUTTON", L"Programmstart durch Terminalserver", BS_GROUPBOX);
g.triggerDescription = MakeStatic(
L"Optionales lokales Programm/Script mit eigenen Parametern. Es wird nur gestartet, wenn PlandentRdpClientTrigger.exe innerhalb derselben RDP-Sitzung auf dem Terminalserver ausgeführt wird.");
g.triggerPathLabel = MakeStatic(L"Programm/Script:");
g.triggerPath = MakeEdit(IDC_TRIGGER_PATH);
g.triggerBrowse = MakeButton(L"...", IDC_TRIGGER_BROWSE);
g.triggerArgsLabel = MakeStatic(L"Argumente:");
g.triggerArgs = MakeEdit(IDC_TRIGGER_ARGS);
g.triggerWorkLabel = MakeStatic(L"Arbeitsverzeichnis:");
g.triggerWork = MakeEdit(IDC_TRIGGER_WORKDIR);
g.triggerWorkBrowse = MakeButton(L"...", IDC_TRIGGER_WORK_BROWSE);
g.triggerEnabled = MakeCheck(L"Start durch Terminalserver-Trigger erlauben", IDC_TRIGGER_ENABLED);
g.triggerHidden = MakeCheck(L"Unsichtbar starten", IDC_TRIGGER_HIDDEN);
g.triggerDup = MakeCheck(L"Mehrfachstart verhindern", IDC_TRIGGER_DUP);
g.triggerTest = MakeButton(L"Trigger-Programm testen", IDC_TRIGGER_TEST);
g.triggerTestStop = MakeButton(L"Test beenden", IDC_TRIGGER_TEST_STOP);
g.status = MakeControl(L"STATIC", L"", SS_LEFT | SS_CENTERIMAGE, WS_EX_CLIENTEDGE);
g.hint = MakeStatic(
L"Hinweis: PlandentRdpClientTrigger.exe auf dem Terminalserver übermittelt ausschließlich einen festen START-Befehl. Programmpfad und Parameter werden niemals vom Server übertragen.");
g.save = MakeButton(L"Konfiguration speichern", IDC_SAVE);
g.install = MakeButton(L"Plugin installieren", IDC_INSTALL);
g.remove = MakeButton(L"Plugin entfernen", IDC_REMOVE);
g.logOpen = MakeButton(L"Log öffnen", IDC_LOG_OPEN);
EnableWindow(g.connectTestStop, FALSE);
EnableWindow(g.triggerTestStop, FALSE);
}
void LayoutControls(int clientWidth)
{
const int margin = 18;
const int contentWidth = std::max(clientWidth - margin * 2, 760);
const int innerX = margin + 14;
const int labelWidth = 132;
const int browseWidth = 42;
const int editX = innerX + labelWidth;
const int editWidth = contentWidth - 28 - labelWidth - browseWidth - 6;
const int browseX = editX + editWidth + 6;
Move(g.title, margin, 14, contentWidth, 30);
Move(g.description, margin, 48, contentWidth, 30);
Move(g.generalGroup, margin, 82, contentWidth, 54);
Move(g.enabled, innerX, 103, 240, 22);
Move(g.logging, innerX + 260, 103, 240, 22);
Move(g.connectGroup, margin, 142, contentWidth, 198);
Move(g.connectDescription, innerX, 162, contentWidth - 28, 26);
Move(g.connectPathLabel, innerX, 191, labelWidth - 4, 22);
Move(g.connectPath, editX, 188, editWidth, 24);
Move(g.connectBrowse, browseX, 188, browseWidth, 25);
Move(g.connectArgsLabel, innerX, 220, labelWidth - 4, 22);
Move(g.connectArgs, editX, 217, editWidth + browseWidth + 6, 24);
Move(g.connectWorkLabel, innerX, 249, labelWidth - 4, 22);
Move(g.connectWork, editX, 246, editWidth, 24);
Move(g.connectWorkBrowse, browseX, 246, browseWidth, 25);
Move(g.connectStart, innerX, 278, 285, 22);
Move(g.connectStop, innerX + 292, 278, 210, 22);
Move(g.connectHidden, innerX + 505, 278, 150, 22);
Move(g.connectDup, innerX + 658, 278, 190, 22);
Move(g.connectTest, innerX, 307, 220, 26);
Move(g.connectTestStop, innerX + 228, 307, 120, 26);
Move(g.triggerGroup, margin, 346, contentWidth, 190);
Move(g.triggerDescription, innerX, 366, contentWidth - 28, 31);
Move(g.triggerPathLabel, innerX, 400, labelWidth - 4, 22);
Move(g.triggerPath, editX, 397, editWidth, 24);
Move(g.triggerBrowse, browseX, 397, browseWidth, 25);
Move(g.triggerArgsLabel, innerX, 429, labelWidth - 4, 22);
Move(g.triggerArgs, editX, 426, editWidth + browseWidth + 6, 24);
Move(g.triggerWorkLabel, innerX, 458, labelWidth - 4, 22);
Move(g.triggerWork, editX, 455, editWidth, 24);
Move(g.triggerWorkBrowse, browseX, 455, browseWidth, 25);
Move(g.triggerEnabled, innerX, 487, 315, 22);
Move(g.triggerHidden, innerX + 322, 487, 150, 22);
Move(g.triggerDup, innerX + 475, 487, 190, 22);
Move(g.triggerTest, innerX, 510, 190, 26);
Move(g.triggerTestStop, innerX + 198, 510, 120, 26);
Move(g.status, margin, 544, contentWidth, 44);
Move(g.hint, margin, 596, contentWidth, 38);
int x = margin;
Move(g.save, x, 642, 178, 28); x += 186;
Move(g.install, x, 642, 150, 28); x += 158;
Move(g.remove, x, 642, 145, 28); x += 153;
Move(g.logOpen, x, 642, 100, 28);
}
config::HookConfig ReadForm()
{
config::HookConfig value;
value.enabled = Checked(g.enabled);
value.enableLogging = Checked(g.logging);
value.scriptPath = Trim(Text(g.connectPath));
value.arguments = Trim(Text(g.connectArgs));
value.workingDirectory = Trim(Text(g.connectWork));
value.startOnConnect = Checked(g.connectStart);
value.stopOnDisconnect = Checked(g.connectStop);
value.hidden = Checked(g.connectHidden);
value.preventDuplicates = Checked(g.connectDup);
value.triggerEnabled = Checked(g.triggerEnabled);
value.triggerProgramPath = Trim(Text(g.triggerPath));
value.triggerArguments = Trim(Text(g.triggerArgs));
value.triggerWorkingDirectory = Trim(Text(g.triggerWork));
value.triggerHidden = Checked(g.triggerHidden);
value.triggerPreventDuplicates = Checked(g.triggerDup);
return value;
}
void RefreshStatus()
{
installer::Status plugin = installer::GetStatus();
std::wstring connectStatus = Checked(g.connectStart)
? (Text(g.connectPath).empty() ? L" RDP-Start: nicht konfiguriert." : L" RDP-Start: konfiguriert.")
: L" RDP-Start: aus.";
std::wstring triggerStatus = Checked(g.triggerEnabled)
? (Text(g.triggerPath).empty() ? L" Server-Trigger: nicht konfiguriert." : L" Server-Trigger: konfiguriert.")
: L" Server-Trigger: aus.";
SetText(g.status, std::wstring(plugin.installed ? L"✓ " : L"○ ") + plugin.text + connectStatus + triggerStatus);
}
void LoadConfiguration()
{
config::HookConfig value = config::Load();
SetChecked(g.enabled, value.enabled);
SetChecked(g.logging, value.enableLogging);
SetText(g.connectPath, value.scriptPath);
SetText(g.connectArgs, value.arguments);
SetText(g.connectWork, value.workingDirectory);
SetChecked(g.connectStart, value.startOnConnect);
SetChecked(g.connectStop, value.stopOnDisconnect);
SetChecked(g.connectHidden, value.hidden);
SetChecked(g.connectDup, value.preventDuplicates);
SetText(g.triggerPath, value.triggerProgramPath);
SetText(g.triggerArgs, value.triggerArguments);
SetText(g.triggerWork, value.triggerWorkingDirectory);
SetChecked(g.triggerEnabled, value.triggerEnabled);
SetChecked(g.triggerHidden, value.triggerHidden);
SetChecked(g.triggerDup, value.triggerPreventDuplicates);
RefreshStatus();
}
bool ValidateLaunchTarget(
const std::wstring& configuredPath,
const std::wstring& configuredWorkingDirectory,
const std::wstring& displayName,
bool required,
std::wstring& error)
{
std::wstring path = config::ExpandEnvironment(configuredPath);
if (path.empty())
{
if (required)
{
error = L"Bitte für „" + displayName + L"“ ein Programm oder Script auswählen.";
return false;
}
return true;
}
if (!FileExists(path))
{
error = displayName + L" wurde nicht gefunden:\n" + path;
return false;
}
if (!SupportedExtension(path))
{
error = displayName + L": Unterstützt werden .bat, .cmd, .ps1 und .exe.";
return false;
}
if (!configuredWorkingDirectory.empty())
{
std::wstring work = config::ExpandEnvironment(configuredWorkingDirectory);
if (!DirectoryExists(work))
{
error = L"Arbeitsverzeichnis für „" + displayName + L"“ wurde nicht gefunden:\n" + work;
return false;
}
}
return true;
}
bool ValidateConfiguration(const config::HookConfig& value, bool installation, std::wstring& error)
{
if (!ValidateLaunchTarget(value.scriptPath, value.workingDirectory, L"RDP-Verbindungsprogramm", false, error) ||
!ValidateLaunchTarget(value.triggerProgramPath, value.triggerWorkingDirectory, L"Trigger-Programm", false, error))
{
return false;
}
if (!installation || !value.enabled)
{
return true;
}
if (value.startOnConnect &&
!ValidateLaunchTarget(value.scriptPath, value.workingDirectory, L"RDP-Verbindungsprogramm", true, error))
{
return false;
}
if (value.triggerEnabled &&
!ValidateLaunchTarget(value.triggerProgramPath, value.triggerWorkingDirectory, L"Trigger-Programm", true, error))
{
return false;
}
return true;
}
bool SaveConfiguration(bool showConfirmation, bool installationValidation)
{
config::HookConfig value = ReadForm();
std::wstring error;
if (!ValidateConfiguration(value, installationValidation, error))
{
ErrorBox(error, L"Konfiguration ist ungültig");
return false;
}
if (!config::Save(value, error))
{
ErrorBox(error, L"Konfiguration konnte nicht gespeichert werden");
return false;
}
RefreshStatus();
if (showConfirmation)
{
InfoBox(L"Konfiguration wurde gespeichert.");
}
return true;
}
void BrowseProgram(HWND pathControl, HWND workControl, HWND argsControl, bool remoteVddsPreset)
{
wchar_t fileBuffer[32768]{};
std::wstring current = config::ExpandEnvironment(Text(pathControl));
if (!current.empty())
{
wcsncpy_s(fileBuffer, current.c_str(), _TRUNCATE);
}
std::wstring initialDir = ParentDirectory(current);
const wchar_t filter[] =
L"Unterstützte Dateien (*.exe;*.bat;*.cmd;*.ps1)\0*.exe;*.bat;*.cmd;*.ps1\0"
L"Alle Dateien (*.*)\0*.*\0\0";
OPENFILENAMEW ofn{};
ofn.lStructSize = sizeof(ofn);
ofn.hwndOwner = g.window;
ofn.lpstrFile = fileBuffer;
ofn.nMaxFile = ARRAYSIZE(fileBuffer);
ofn.lpstrFilter = filter;
ofn.lpstrTitle = L"Programm oder Script auswählen";
ofn.lpstrInitialDir = initialDir.empty() ? nullptr : initialDir.c_str();
ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR;
if (!GetOpenFileNameW(&ofn))
{
return;
}
SetText(pathControl, fileBuffer);
if (Text(workControl).empty())
{
SetText(workControl, ParentDirectory(fileBuffer));
}
if (remoteVddsPreset && Text(argsControl).empty())
{
std::wstring fileName = Lower(FileName(fileBuffer));
if (fileName.find(L"remotevdds_receiver_ts") != std::wstring::npos)
{
SetText(argsControl, L"MIN");
}
}
RefreshStatus();
}
void BrowseDirectory(HWND target)
{
IFileDialog* dialog = nullptr;
HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog));
if (FAILED(hr) || dialog == nullptr)
{
ErrorBox(L"Ordnerauswahl konnte nicht geöffnet werden.");
return;
}
DWORD options = 0;
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
dialog->SetTitle(L"Arbeitsverzeichnis auswählen");
std::wstring current = config::ExpandEnvironment(Text(target));
if (DirectoryExists(current))
{
IShellItem* folder = nullptr;
if (SUCCEEDED(SHCreateItemFromParsingName(current.c_str(), nullptr, IID_PPV_ARGS(&folder))) && folder != nullptr)
{
dialog->SetFolder(folder);
folder->Release();
}
}
if (SUCCEEDED(dialog->Show(g.window)))
{
IShellItem* item = nullptr;
if (SUCCEEDED(dialog->GetResult(&item)) && item != nullptr)
{
PWSTR path = nullptr;
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path != nullptr)
{
SetText(target, path);
CoTaskMemFree(path);
}
item->Release();
}
}
dialog->Release();
}
testlaunch::LaunchSettings ConnectionLaunchSettings(const config::HookConfig& value)
{
return { value.scriptPath, value.arguments, value.workingDirectory, value.hidden };
}
testlaunch::LaunchSettings TriggerLaunchSettings(const config::HookConfig& value)
{
return { value.triggerProgramPath, value.triggerArguments, value.triggerWorkingDirectory, value.triggerHidden };
}
void StartConnectionTest()
{
config::HookConfig value = ReadForm();
std::wstring error;
if (!ValidateLaunchTarget(value.scriptPath, value.workingDirectory, L"RDP-Verbindungsprogramm", true, error))
{
ErrorBox(error, L"Test fehlgeschlagen");
return;
}
if (g.connectTestProcess.IsRunning())
{
g.connectTestProcess.Stop(error);
}
if (!g.connectTestProcess.Start(ConnectionLaunchSettings(value), error))
{
ErrorBox(error, L"Test fehlgeschlagen");
return;
}
EnableWindow(g.connectTestStop, TRUE);
SetText(g.status, L"Verbindungs-Test gestartet: PID " + std::to_wstring(g.connectTestProcess.ProcessId()) + L". Der Test läuft unabhängig von MSTSC.");
}
void StartTriggerTest()
{
config::HookConfig value = ReadForm();
std::wstring error;
if (!ValidateLaunchTarget(value.triggerProgramPath, value.triggerWorkingDirectory, L"Trigger-Programm", true, error))
{
ErrorBox(error, L"Trigger-Programm-Test fehlgeschlagen");
return;
}
if (g.triggerTestProcess.IsRunning())
{
g.triggerTestProcess.Stop(error);
}
if (!g.triggerTestProcess.Start(TriggerLaunchSettings(value), error))
{
ErrorBox(error, L"Trigger-Programm-Test fehlgeschlagen");
return;
}
EnableWindow(g.triggerTestStop, TRUE);
SetText(g.status, L"Trigger-Programm-Test gestartet: PID " + std::to_wstring(g.triggerTestProcess.ProcessId()) + L". Der Test läuft ohne Terminalserver-Trigger.");
}
void StopTest(testlaunch::TestProcess& process, HWND button, const wchar_t* description)
{
std::wstring error;
if (!process.Stop(error))
{
WarningBox(error, description);
return;
}
EnableWindow(button, FALSE);
SetText(g.status, std::wstring(description) + L" wurde beendet.");
}
void InstallPlugin()
{
if (!SaveConfiguration(false, true))
{
return;
}
std::wstring error;
if (!installer::Install(error))
{
ErrorBox(error, L"Installation fehlgeschlagen");
return;
}
RefreshStatus();
InfoBox(
L"Plugin wurde für den aktuellen Benutzer installiert und die Konfiguration gespeichert.\n\n"
L"Bereits geöffnete mstsc.exe-Fenster vollständig schließen und anschließend neu starten, damit die neue DLL geladen wird.",
L"Installation abgeschlossen");
}
void RemovePlugin()
{
std::wstring message;
bool fullyRemoved = false;
if (!installer::Uninstall(message, fullyRemoved))
{
ErrorBox(message, L"Plugin entfernen");
return;
}
RefreshStatus();
if (fullyRemoved)
{
InfoBox(message, L"Plugin entfernen");
}
else
{
WarningBox(message, L"Plugin entfernen");
}
}
void OpenLog()
{
std::wstring path = installer::LogPath();
if (!FileExists(path))
{
InfoBox(L"Es existiert noch kein Plugin-Log.");
return;
}
HINSTANCE result = ShellExecuteW(g.window, L"open", path.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
if (reinterpret_cast<INT_PTR>(result) <= 32)
{
ErrorBox(L"Plugin-Log konnte nicht geöffnet werden.");
}
}
void HandleTimer()
{
bool connectionRunning = g.connectTestProcess.IsRunning();
bool triggerRunning = g.triggerTestProcess.IsRunning();
EnableWindow(g.connectTestStop, connectionRunning ? TRUE : FALSE);
EnableWindow(g.triggerTestStop, triggerRunning ? TRUE : FALSE);
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_CREATE:
g.window = hwnd;
g.normalFont = static_cast<HFONT>(GetStockObject(DEFAULT_GUI_FONT));
g.titleFont = CreateFontW(
-24, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI");
CreateControls();
LoadConfiguration();
SetTimer(hwnd, IDC_TIMER, 1000, nullptr);
return 0;
case WM_SIZE:
LayoutControls(LOWORD(lParam));
return 0;
case WM_GETMINMAXINFO:
{
auto* info = reinterpret_cast<MINMAXINFO*>(lParam);
info->ptMinTrackSize.x = 920;
info->ptMinTrackSize.y = 720;
return 0;
}
case WM_TIMER:
if (wParam == IDC_TIMER)
{
HandleTimer();
}
return 0;
case WM_COMMAND:
switch (LOWORD(wParam))
{
case IDC_CONN_BROWSE:
BrowseProgram(g.connectPath, g.connectWork, g.connectArgs, true);
break;
case IDC_CONN_WORK_BROWSE:
BrowseDirectory(g.connectWork);
break;
case IDC_TRIGGER_BROWSE:
BrowseProgram(g.triggerPath, g.triggerWork, g.triggerArgs, false);
break;
case IDC_TRIGGER_WORK_BROWSE:
BrowseDirectory(g.triggerWork);
break;
case IDC_CONN_TEST:
StartConnectionTest();
break;
case IDC_CONN_TEST_STOP:
StopTest(g.connectTestProcess, g.connectTestStop, L"Verbindungs-Test");
break;
case IDC_TRIGGER_TEST:
StartTriggerTest();
break;
case IDC_TRIGGER_TEST_STOP:
StopTest(g.triggerTestProcess, g.triggerTestStop, L"Trigger-Programm-Test");
break;
case IDC_SAVE:
SaveConfiguration(true, false);
break;
case IDC_INSTALL:
InstallPlugin();
break;
case IDC_REMOVE:
RemovePlugin();
break;
case IDC_LOG_OPEN:
OpenLog();
break;
case IDC_ENABLED:
case IDC_CONN_START:
case IDC_TRIGGER_ENABLED:
if (HIWORD(wParam) == BN_CLICKED)
{
RefreshStatus();
}
break;
default:
break;
}
return 0;
case WM_DESTROY:
KillTimer(hwnd, IDC_TIMER);
// Wie beim bisherigen .NET-Konfigurator werden Testprozesse beim Schließen
// der GUI nicht zwangsweise beendet; nur die lokalen Handles werden freigegeben.
g.connectTestProcess.Detach();
g.triggerTestProcess.Detach();
if (g.titleFont != nullptr)
{
DeleteObject(g.titleFont);
g.titleFont = nullptr;
}
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
}
int APIENTRY wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int showCommand)
{
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
HRESULT com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
INITCOMMONCONTROLSEX controls{};
controls.dwSize = sizeof(controls);
controls.dwICC = ICC_STANDARD_CLASSES;
InitCommonControlsEx(&controls);
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = WindowProc;
wc.hInstance = instance;
wc.hIcon = LoadIconW(nullptr, IDI_APPLICATION);
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_WINDOW + 1);
wc.lpszClassName = kWindowClass;
wc.hIconSm = LoadIconW(nullptr, IDI_APPLICATION);
if (!RegisterClassExW(&wc))
{
if (SUCCEEDED(com)) CoUninitialize();
return 1;
}
HWND window = CreateWindowExW(
0,
kWindowClass,
kTitle,
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
940,
730,
nullptr,
nullptr,
instance,
nullptr);
if (window == nullptr)
{
if (SUCCEEDED(com)) CoUninitialize();
return 2;
}
ShowWindow(window, showCommand);
UpdateWindow(window);
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0)
{
TranslateMessage(&message);
DispatchMessageW(&message);
}
if (SUCCEEDED(com)) CoUninitialize();
return static_cast<int>(message.wParam);
}
-699
View File
@@ -1,699 +0,0 @@
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
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 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 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()
{
AutoSize = false,
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleLeft,
Padding = new Padding(8)
};
public MainForm()
{
Text = "Plandent MSTSC Script Hook";
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(820, 720);
Size = new Size(900, 800);
Font = new Font("Segoe UI", 9F);
Controls.Add(BuildMainLayout());
Load += (_, _) => LoadConfiguration();
FormClosed += (_, _) => DisposeAllTestProcesses();
}
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.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
{
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 = "Lokale Programme können automatisch beim RDP-Verbindungsaufbau oder gezielt durch eine Anwendung innerhalb der Terminalserver-Sitzung gestartet werden.",
AutoSize = true,
MaximumSize = new Size(820, 0),
Margin = new Padding(0, 0, 0, 12)
};
root.Controls.Add(description, 0, 1);
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 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
{
Dock = DockStyle.Top,
AutoSize = true,
ColumnCount = 3,
RowCount = 3,
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("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);
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 BuildStatusPanel()
{
var panel = new Panel
{
Dock = DockStyle.Top,
Height = 70,
BorderStyle = BorderStyle.FixedSingle,
Margin = new Padding(0, 0, 0, 10)
};
panel.Controls.Add(statusLabel);
return panel;
}
private Control BuildMainButtons()
{
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = true,
Margin = new Padding(0)
};
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(saveButton);
buttons.Controls.Add(installButton);
buttons.Controls.Add(removeButton);
buttons.Controls.Add(logButton);
return buttons;
}
private void BrowseProgram(TextBox pathTextBox, TextBox workingDirectoryTextBox, bool useRemoteVddsPreset)
{
using var dialog = new OpenFileDialog
{
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(pathTextBox.Text))
{
string expanded = Environment.ExpandEnvironmentVariables(pathTextBox.Text);
if (File.Exists(expanded))
{
dialog.InitialDirectory = Path.GetDirectoryName(expanded);
dialog.FileName = Path.GetFileName(expanded);
}
}
if (dialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
pathTextBox.Text = dialog.FileName;
if (string.IsNullOrWhiteSpace(workingDirectoryTextBox.Text))
{
workingDirectoryTextBox.Text = Path.GetDirectoryName(dialog.FileName) ?? string.Empty;
}
if (useRemoteVddsPreset &&
string.IsNullOrWhiteSpace(argumentsTextBox.Text) &&
Path.GetFileName(dialog.FileName).Contains("RemoteVDDS_Receiver_TS", StringComparison.OrdinalIgnoreCase))
{
argumentsTextBox.Text = "MIN";
}
}
private void BrowseWorkingDirectory(TextBox target)
{
using var dialog = new FolderBrowserDialog
{
Description = "Arbeitsverzeichnis auswählen",
UseDescriptionForTitle = true,
ShowNewFolderButton = true
};
string expanded = Environment.ExpandEnvironmentVariables(target.Text);
if (Directory.Exists(expanded))
{
dialog.SelectedPath = expanded;
}
if (dialog.ShowDialog(this) == DialogResult.OK)
{
target.Text = dialog.SelectedPath;
}
}
private HookConfig ReadForm()
{
return new HookConfig
{
Enabled = enabledCheckBox.Checked,
EnableLogging = loggingCheckBox.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,
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;
startOnConnectCheckBox.Checked = config.StartOnConnect;
stopOnDisconnectCheckBox.Checked = config.StopOnDisconnect;
hiddenCheckBox.Checked = config.Hidden;
preventDuplicatesCheckBox.Checked = config.PreventDuplicates;
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();
}
private bool SaveConfiguration(bool showConfirmation)
{
try
{
HookConfig config = ReadForm();
ValidateConfiguredTargets(config, installationValidation: 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();
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 neue 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 StartConnectionTest()
{
try
{
StopConnectionTest(silent: true);
HookConfig config = ReadForm();
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, "Test fehlgeschlagen", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void StartTriggerTest()
{
try
{
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 (!process.HasExited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(3000);
}
if (!silent)
{
statusLabel.Text = description + " wurde beendet.";
}
}
catch (Exception ex)
{
if (!silent)
{
MessageBox.Show(this, ex.Message, description + " konnte nicht beendet werden", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
finally
{
process?.Dispose();
process = null;
button.Enabled = false;
}
}
private void DisposeAllTestProcesses()
{
connectionTestProcess?.Dispose();
triggerTestProcess?.Dispose();
connectionTestProcess = null;
triggerTestProcess = null;
}
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 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 ValidateConfiguredTargets(HookConfig config, bool installationValidation)
{
// 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;
}
if (config.StartOnConnect)
{
ValidateLaunchTarget(config.ScriptPath, config.WorkingDirectory, "RDP-Verbindungsprogramm", required: true);
}
if (config.TriggerEnabled)
{
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;
}
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($"{displayName}: Unterstützt werden .bat, .cmd, .ps1 und .exe.");
}
if (!string.IsNullOrWhiteSpace(configuredWorkingDirectory))
{
string workDir = Environment.ExpandEnvironmentVariables(configuredWorkingDirectory);
if (!Directory.Exists(workDir))
{
throw new DirectoryNotFoundException($"Arbeitsverzeichnis für „{displayName}“ wurde nicht gefunden: {workDir}");
}
}
}
}
+227
View File
@@ -0,0 +1,227 @@
#include "PluginInstaller.h"
#include "Config.h"
#include "resource.h"
#include <windows.h>
#include <shlobj.h>
#include <vector>
namespace
{
constexpr wchar_t kDllFileName[] = L"PlandentMstscScriptHook.dll";
std::wstring JoinPath(const std::wstring& left, const std::wstring& right)
{
if (left.empty())
{
return right;
}
if (left.back() == L'\\' || left.back() == L'/')
{
return left + right;
}
return left + L"\\" + right;
}
std::wstring ModuleDirectory()
{
std::vector<wchar_t> buffer(32768, L'\0');
DWORD len = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
if (len == 0 || len >= buffer.size())
{
return {};
}
std::wstring path(buffer.data(), len);
size_t slash = path.find_last_of(L"\\/");
return slash == std::wstring::npos ? std::wstring{} : path.substr(0, slash);
}
bool EnsureDirectory(const std::wstring& path, std::wstring& error)
{
if (CreateDirectoryW(path.c_str(), nullptr) || GetLastError() == ERROR_ALREADY_EXISTS)
{
return true;
}
error = L"Verzeichnis konnte nicht erstellt werden: " + path + L" (Fehler " + std::to_wstring(GetLastError()) + L")";
return false;
}
bool ExtractResourceToFile(const std::wstring& destination, std::wstring& error)
{
HMODULE module = GetModuleHandleW(nullptr);
HRSRC resource = FindResourceW(module, MAKEINTRESOURCEW(IDR_PLUGIN_DLL), RT_RCDATA);
if (resource == nullptr)
{
// Fallback für Entwicklung: Plugin-DLL direkt neben der EXE akzeptieren.
std::wstring adjacent = JoinPath(ModuleDirectory(), kDllFileName);
DWORD attr = GetFileAttributesW(adjacent.c_str());
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY) == 0)
{
if (CopyFileW(adjacent.c_str(), destination.c_str(), FALSE))
{
return true;
}
error = L"Plugin-DLL konnte nicht kopiert werden (Fehler " + std::to_wstring(GetLastError()) + L").";
return false;
}
error = L"Die native Plugin-DLL ist weder eingebettet noch neben der Konfigurations-EXE vorhanden.";
return false;
}
HGLOBAL loaded = LoadResource(module, resource);
DWORD size = SizeofResource(module, resource);
const void* data = loaded != nullptr ? LockResource(loaded) : nullptr;
if (data == nullptr || size == 0)
{
error = L"Eingebettete Plugin-DLL konnte nicht gelesen werden.";
return false;
}
HANDLE file = CreateFileW(
destination.c_str(),
GENERIC_WRITE,
0,
nullptr,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
nullptr);
if (file == INVALID_HANDLE_VALUE)
{
error = L"Plugin-DLL konnte nicht geschrieben werden: " + destination + L" (Fehler " + std::to_wstring(GetLastError()) + L")";
return false;
}
DWORD written = 0;
BOOL ok = WriteFile(file, data, size, &written, nullptr);
DWORD writeError = ok ? ERROR_SUCCESS : GetLastError();
CloseHandle(file);
if (!ok || written != size)
{
DeleteFileW(destination.c_str());
error = L"Plugin-DLL konnte nicht vollständig geschrieben werden (Fehler " + std::to_wstring(writeError) + L").";
return false;
}
return true;
}
}
namespace installer
{
std::wstring InstallDirectory()
{
PWSTR localAppData = nullptr;
HRESULT hr = SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, nullptr, &localAppData);
if (FAILED(hr) || localAppData == nullptr)
{
return {};
}
std::wstring result = std::wstring(localAppData) + L"\\Plandent\\MstscScriptHook";
CoTaskMemFree(localAppData);
return result;
}
std::wstring InstalledDllPath()
{
return JoinPath(InstallDirectory(), kDllFileName);
}
std::wstring LogPath()
{
return JoinPath(InstallDirectory(), L"plugin.log");
}
bool Install(std::wstring& error)
{
PWSTR localAppData = nullptr;
HRESULT hr = SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, nullptr, &localAppData);
if (FAILED(hr) || localAppData == nullptr)
{
error = L"%LOCALAPPDATA% konnte nicht ermittelt werden.";
return false;
}
std::wstring base(localAppData);
CoTaskMemFree(localAppData);
std::wstring plandent = JoinPath(base, L"Plandent");
std::wstring targetDir = JoinPath(plandent, L"MstscScriptHook");
if (!EnsureDirectory(plandent, error) || !EnsureDirectory(targetDir, error))
{
return false;
}
const std::wstring targetDll = JoinPath(targetDir, kDllFileName);
if (!ExtractResourceToFile(targetDll, error))
{
return false;
}
if (!config::RegisterPlugin(targetDll, error))
{
return false;
}
return true;
}
bool Uninstall(std::wstring& message, bool& fullyRemoved)
{
fullyRemoved = false;
std::wstring error;
if (!config::UnregisterPlugin(error))
{
message = error;
return false;
}
std::wstring dll = InstalledDllPath();
DWORD attr = GetFileAttributesW(dll.c_str());
if (attr == INVALID_FILE_ATTRIBUTES)
{
fullyRemoved = true;
message = L"Plugin-Registrierung wurde entfernt.";
return true;
}
if (DeleteFileW(dll.c_str()))
{
RemoveDirectoryW(InstallDirectory().c_str()); // Darf fehlschlagen, z. B. wenn plugin.log existiert.
fullyRemoved = true;
message = L"Plugin-Registrierung und DLL wurden entfernt.";
return true;
}
DWORD lastError = GetLastError();
if (lastError == ERROR_SHARING_VIOLATION || lastError == ERROR_ACCESS_DENIED)
{
message = L"Die Registrierung wurde entfernt. Die DLL wird wahrscheinlich noch von mstsc.exe verwendet. "
L"Alle Remote-Desktop-Clients schließen und anschließend erneut auf „Plugin entfernen“ klicken.";
return true;
}
message = L"Registrierung wurde entfernt, die DLL konnte aber nicht gelöscht werden (Fehler " + std::to_wstring(lastError) + L").";
return true;
}
Status GetStatus()
{
Status status;
std::wstring registered = config::GetRegisteredPluginPath();
if (registered.empty())
{
status.text = L"Plugin ist nicht registriert.";
return status;
}
std::wstring expanded = config::ExpandEnvironment(registered);
DWORD attr = GetFileAttributesW(expanded.c_str());
if (attr == INVALID_FILE_ATTRIBUTES || (attr & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
status.text = L"Plugin ist registriert, DLL fehlt jedoch: " + expanded;
return status;
}
status.installed = true;
status.text = L"Plugin ist registriert: " + expanded;
return status;
}
}
-113
View File
@@ -1,113 +0,0 @@
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);
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <string>
namespace installer
{
struct Status
{
bool installed = false;
std::wstring text;
};
std::wstring InstallDirectory();
std::wstring InstalledDllPath();
std::wstring LogPath();
bool Install(std::wstring& error);
bool Uninstall(std::wstring& message, bool& fullyRemoved);
Status GetStatus();
}
-13
View File
@@ -1,13 +0,0 @@
using System.Windows.Forms;
namespace Plandent.MstscScriptHook.Configurator;
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
+212
View File
@@ -0,0 +1,212 @@
#include "Config.h"
#include <windows.h>
#include <vector>
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<BYTE*>(&value), &size) != ERROR_SUCCESS ||
type != REG_DWORD)
{
return defaultValue;
}
return value;
}
std::wstring ReadStringRaw(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<wchar_t> buffer((size / sizeof(wchar_t)) + 1, L'\0');
if (RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast<BYTE*>(buffer.data()), &size) != ERROR_SUCCESS)
{
return {};
}
return std::wstring(buffer.data());
}
bool WriteDword(HKEY key, const wchar_t* name, bool value, std::wstring& error)
{
DWORD data = value ? 1u : 0u;
LONG result = RegSetValueExW(key, name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&data), sizeof(data));
if (result != ERROR_SUCCESS)
{
error = L"Registry-Wert konnte nicht geschrieben werden: " + std::wstring(name) + L" (Fehler " + std::to_wstring(result) + L")";
return false;
}
return true;
}
bool WriteString(HKEY key, const wchar_t* name, const std::wstring& value, DWORD type, std::wstring& error)
{
const DWORD bytes = static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t));
LONG result = RegSetValueExW(
key,
name,
0,
type,
reinterpret_cast<const BYTE*>(value.c_str()),
bytes);
if (result != ERROR_SUCCESS)
{
error = L"Registry-Wert konnte nicht geschrieben werden: " + std::wstring(name) + L" (Fehler " + std::to_wstring(result) + L")";
return false;
}
return true;
}
}
namespace config
{
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<wchar_t> buffer(needed, L'\0');
if (ExpandEnvironmentStringsW(value.c_str(), buffer.data(), needed) == 0)
{
return value;
}
return std::wstring(buffer.data());
}
HookConfig Load()
{
HookConfig value;
HKEY key = nullptr;
if (RegOpenKeyExW(HKEY_CURRENT_USER, kConfigKey, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS)
{
return value;
}
value.enabled = ReadDword(key, L"Enabled", 1) != 0;
value.enableLogging = ReadDword(key, L"EnableLogging", 1) != 0;
value.scriptPath = ReadStringRaw(key, L"ScriptPath");
value.arguments = ReadStringRaw(key, L"Arguments");
value.workingDirectory = ReadStringRaw(key, L"WorkingDirectory");
value.startOnConnect = ReadDword(key, L"StartOnConnect", 1) != 0;
value.stopOnDisconnect = ReadDword(key, L"StopOnDisconnect", 0) != 0;
value.hidden = ReadDword(key, L"Hidden", 1) != 0;
value.preventDuplicates = ReadDword(key, L"PreventDuplicates", 1) != 0;
value.triggerEnabled = ReadDword(key, L"TriggerEnabled", 0) != 0;
value.triggerProgramPath = ReadStringRaw(key, L"TriggerProgramPath");
value.triggerArguments = ReadStringRaw(key, L"TriggerArguments");
value.triggerWorkingDirectory = ReadStringRaw(key, L"TriggerWorkingDirectory");
value.triggerHidden = ReadDword(key, L"TriggerHidden", 1) != 0;
value.triggerPreventDuplicates = ReadDword(key, L"TriggerPreventDuplicates", 1) != 0;
RegCloseKey(key);
return value;
}
bool Save(const HookConfig& value, std::wstring& error)
{
HKEY key = nullptr;
DWORD disposition = 0;
LONG result = RegCreateKeyExW(
HKEY_CURRENT_USER,
kConfigKey,
0,
nullptr,
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
nullptr,
&key,
&disposition);
if (result != ERROR_SUCCESS)
{
error = L"Konfigurationsschlüssel konnte nicht erstellt werden (Fehler " + std::to_wstring(result) + L").";
return false;
}
bool ok =
WriteDword(key, L"Enabled", value.enabled, error) &&
WriteDword(key, L"EnableLogging", value.enableLogging, error) &&
WriteString(key, L"ScriptPath", value.scriptPath, REG_EXPAND_SZ, error) &&
WriteString(key, L"Arguments", value.arguments, REG_SZ, error) &&
WriteString(key, L"WorkingDirectory", value.workingDirectory, REG_EXPAND_SZ, error) &&
WriteDword(key, L"StartOnConnect", value.startOnConnect, error) &&
WriteDword(key, L"StopOnDisconnect", value.stopOnDisconnect, error) &&
WriteDword(key, L"Hidden", value.hidden, error) &&
WriteDword(key, L"PreventDuplicates", value.preventDuplicates, error) &&
WriteDword(key, L"TriggerEnabled", value.triggerEnabled, error) &&
WriteString(key, L"TriggerProgramPath", value.triggerProgramPath, REG_EXPAND_SZ, error) &&
WriteString(key, L"TriggerArguments", value.triggerArguments, REG_SZ, error) &&
WriteString(key, L"TriggerWorkingDirectory", value.triggerWorkingDirectory, REG_EXPAND_SZ, error) &&
WriteDword(key, L"TriggerHidden", value.triggerHidden, error) &&
WriteDword(key, L"TriggerPreventDuplicates", value.triggerPreventDuplicates, error);
RegCloseKey(key);
return ok;
}
std::wstring GetRegisteredPluginPath()
{
HKEY key = nullptr;
if (RegOpenKeyExW(HKEY_CURRENT_USER, kAddInKey, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS)
{
return {};
}
std::wstring value = ReadStringRaw(key, L"Name");
RegCloseKey(key);
return value;
}
bool RegisterPlugin(const std::wstring& dllPath, std::wstring& error)
{
HKEY key = nullptr;
DWORD disposition = 0;
LONG result = RegCreateKeyExW(
HKEY_CURRENT_USER,
kAddInKey,
0,
nullptr,
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
nullptr,
&key,
&disposition);
if (result != ERROR_SUCCESS)
{
error = L"MSTSC AddIn-Schlüssel konnte nicht erstellt werden (Fehler " + std::to_wstring(result) + L").";
return false;
}
bool ok = WriteString(key, L"Name", dllPath, REG_SZ, error);
RegCloseKey(key);
return ok;
}
bool UnregisterPlugin(std::wstring& error)
{
LONG result = RegDeleteTreeW(HKEY_CURRENT_USER, kAddInKey);
if (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND || result == ERROR_PATH_NOT_FOUND)
{
return true;
}
error = L"MSTSC-Plugin-Registrierung konnte nicht entfernt werden (Fehler " + std::to_wstring(result) + L").";
return false;
}
}
-97
View File
@@ -1,97 +0,0 @@
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.EnableLogging = ReadBool(key, "EnableLogging", 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.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;
}
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("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);
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("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()
{
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;
}
}
-169
View File
@@ -1,169 +0,0 @@
using System.Diagnostics;
namespace Plandent.MstscScriptHook.Configurator;
internal static class ScriptTestLauncher
{
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 programPath = Environment.ExpandEnvironmentVariables(configuredPath ?? string.Empty);
if (string.IsNullOrWhiteSpace(programPath) || !File.Exists(programPath))
{
throw new FileNotFoundException("Das ausgewählte Programm/Script wurde nicht gefunden.", programPath);
}
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 = hidden,
WindowStyle = 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(programPath, configuredArguments));
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(programPath);
AddRawArguments(startInfo, configuredArguments);
break;
case ".exe":
startInfo.FileName = programPath;
AddRawArguments(startInfo, configuredArguments);
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 programPath, string? arguments)
{
string command = QuoteForCmd(programPath);
if (!string.IsNullOrWhiteSpace(arguments))
{
command += " " + arguments;
}
return command;
}
private static string QuoteForCmd(string value)
{
return "\"" + value.Replace("\"", "\\\"") + "\"";
}
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<string> Split(string commandLine)
{
var result = new List<string>();
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;
}
}
+299
View File
@@ -0,0 +1,299 @@
#include "TestLauncher.h"
#include "Config.h"
#include <algorithm>
#include <cwctype>
#include <vector>
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 ExtensionLower(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<wchar_t>(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 EnvironmentValue(const wchar_t* name)
{
DWORD size = GetEnvironmentVariableW(name, nullptr, 0);
if (size == 0)
{
return {};
}
std::vector<wchar_t> buffer(size, L'\0');
if (GetEnvironmentVariableW(name, buffer.data(), size) == 0)
{
return {};
}
return std::wstring(buffer.data());
}
struct LaunchCommand
{
std::wstring application;
std::wstring commandLine;
};
bool BuildCommand(const testlaunch::LaunchSettings& settings, LaunchCommand& command, std::wstring& error)
{
const std::wstring extension = ExtensionLower(settings.path);
if (extension == L".bat" || extension == L".cmd")
{
std::wstring comspec = EnvironmentValue(L"ComSpec");
if (comspec.empty())
{
std::wstring windows = EnvironmentValue(L"SystemRoot");
if (windows.empty())
{
error = L"ComSpec/SystemRoot konnte nicht ermittelt werden.";
return false;
}
comspec = windows + L"\\System32\\cmd.exe";
}
std::wstring inner = QuoteWindowsArgument(settings.path);
if (!settings.arguments.empty())
{
inner += L" " + settings.arguments;
}
command.application = comspec;
command.commandLine = QuoteWindowsArgument(comspec) + L" /D /S /C \"" + inner + L"\"";
return true;
}
if (extension == L".ps1")
{
std::wstring windows = EnvironmentValue(L"SystemRoot");
if (windows.empty())
{
error = L"SystemRoot konnte nicht ermittelt werden.";
return false;
}
std::wstring powershell = windows + L"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
command.application = powershell;
command.commandLine = QuoteWindowsArgument(powershell) +
L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " +
QuoteWindowsArgument(settings.path);
if (!settings.arguments.empty())
{
command.commandLine += L" " + settings.arguments;
}
return true;
}
if (extension == L".exe")
{
command.application = settings.path;
command.commandLine = QuoteWindowsArgument(settings.path);
if (!settings.arguments.empty())
{
command.commandLine += L" " + settings.arguments;
}
return true;
}
error = L"Unterstützt werden .bat, .cmd, .ps1 und .exe.";
return false;
}
std::wstring Win32ErrorMessage(DWORD error)
{
wchar_t* text = nullptr;
DWORD size = FormatMessageW(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr,
error,
0,
reinterpret_cast<wchar_t*>(&text),
0,
nullptr);
if (size == 0 || text == nullptr)
{
return L"Win32-Fehler " + std::to_wstring(error);
}
std::wstring result(text, size);
LocalFree(text);
while (!result.empty() && (result.back() == L'\r' || result.back() == L'\n' || result.back() == L' '))
{
result.pop_back();
}
return result + L" (" + std::to_wstring(error) + L")";
}
}
namespace testlaunch
{
TestProcess::~TestProcess()
{
Detach();
}
void TestProcess::CleanupExited()
{
if (process_ != nullptr && WaitForSingleObject(process_, 0) != WAIT_TIMEOUT)
{
CloseHandle(process_);
process_ = nullptr;
processId_ = 0;
}
}
bool TestProcess::IsRunning()
{
CleanupExited();
return process_ != nullptr;
}
void TestProcess::Detach()
{
if (process_ != nullptr)
{
CloseHandle(process_);
process_ = nullptr;
processId_ = 0;
}
}
bool TestProcess::Start(const LaunchSettings& rawSettings, std::wstring& error)
{
CleanupExited();
if (process_ != nullptr)
{
error = L"Der Testprozess läuft bereits.";
return false;
}
LaunchSettings settings = rawSettings;
settings.path = config::ExpandEnvironment(settings.path);
settings.workingDirectory = config::ExpandEnvironment(settings.workingDirectory);
DWORD attr = GetFileAttributesW(settings.path.c_str());
if (settings.path.empty() || attr == INVALID_FILE_ATTRIBUTES || (attr & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
error = L"Das ausgewählte Programm/Script wurde nicht gefunden: " + settings.path;
return false;
}
LaunchCommand command;
if (!BuildCommand(settings, command, error))
{
return false;
}
std::wstring workingDirectory = settings.workingDirectory.empty()
? ParentDirectory(settings.path)
: settings.workingDirectory;
std::vector<wchar_t> mutableCommand(command.commandLine.begin(), command.commandLine.end());
mutableCommand.push_back(L'\0');
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
if (settings.hidden)
{
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
}
PROCESS_INFORMATION pi{};
DWORD flags = settings.hidden ? CREATE_NO_WINDOW : 0;
BOOL created = CreateProcessW(
command.application.c_str(),
mutableCommand.data(),
nullptr,
nullptr,
FALSE,
flags,
nullptr,
workingDirectory.empty() ? nullptr : workingDirectory.c_str(),
&startup,
&pi);
if (!created)
{
error = L"Testprozess konnte nicht gestartet werden: " + Win32ErrorMessage(GetLastError());
return false;
}
CloseHandle(pi.hThread);
process_ = pi.hProcess;
processId_ = pi.dwProcessId;
return true;
}
bool TestProcess::Stop(std::wstring& error)
{
CleanupExited();
if (process_ == nullptr)
{
return true;
}
if (!TerminateProcess(process_, 0))
{
error = L"Testprozess konnte nicht beendet werden: " + Win32ErrorMessage(GetLastError());
return false;
}
WaitForSingleObject(process_, 3000);
CloseHandle(process_);
process_ = nullptr;
processId_ = 0;
return true;
}
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <windows.h>
#include <string>
namespace testlaunch
{
struct LaunchSettings
{
std::wstring path;
std::wstring arguments;
std::wstring workingDirectory;
bool hidden = true;
};
class TestProcess
{
public:
~TestProcess();
TestProcess(const TestProcess&) = delete;
TestProcess& operator=(const TestProcess&) = delete;
TestProcess() = default;
bool Start(const LaunchSettings& settings, std::wstring& error);
bool Stop(std::wstring& error);
bool IsRunning();
DWORD ProcessId() const { return processId_; }
void Detach();
private:
HANDLE process_ = nullptr;
DWORD processId_ = 0;
void CleanupExited();
};
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#define IDR_PLUGIN_DLL 101
+643
View File
@@ -0,0 +1,643 @@
# Entwicklerdokumentation – Plandent MSTSC Script Hook
## 1. Ziel des Projekts
Der Plandent MSTSC Script Hook verbindet Programme innerhalb einer Microsoft-RDP-Sitzung mit lokal auf dem RDP-Client ausgeführten Programmen.
Der aktuelle Projektumfang ist bewusst auf drei konkrete Workflows ausgerichtet:
1. automatischer lokaler Start eines Programms oder Scripts beim erfolgreichen RDP-Verbindungsaufbau,
2. expliziter lokaler Programmstart durch einen festen Trigger innerhalb der RDP-Sitzung,
3. SIOMIN-/SLIDA-Bridge zwischen einem Terminalprogramm und einem lokal installierten Sidexis.
Der Terminalserver darf dabei keinen beliebigen lokalen Programmpfad oder beliebige Startparameter vorgeben. Programmpfad und Parameter werden ausschließlich lokal auf dem Client konfiguriert.
## 2. Projektstruktur
```text
PlandentMstscScriptHook/
├── Configurator/
│ ├── Main.cpp
│ ├── Config.h
│ ├── RegistryConfig.cpp
│ ├── PluginInstaller.cpp
│ ├── TestLauncher.cpp
│ └── Configurator.vcxproj
│
├── Plugin/
│ ├── DllMain.cpp
│ ├── ScriptHookPlugin.cpp
│ ├── RegistryConfig.cpp
│ ├── ProcessLauncher.cpp
│ ├── Logging.cpp
│ ├── exports.def
│ └── PlandentMstscScriptHook.vcxproj
│
├── ServerTrigger/
│ ├── Main.cpp
│ └── PlandentRdpClientTrigger.vcxproj
│
├── SiominTrigger/
│ ├── Main.cpp
│ └── PlandentSiominClientTrigger.vcxproj
│
├── Shared/
│ ├── TriggerProtocol.h
│ └── DvcServerIo.h
│
├── examples/
├── build-release.ps1
└── PlandentMstscScriptHook.sln
```
Alle Projekte sind native Windows-x64-C++-Projekte. Der frühere .NET-Konfigurator wurde vollständig entfernt.
## 3. Build
### Voraussetzungen
- Visual Studio 2022 oder entsprechende Build Tools
- Workload **Desktopentwicklung mit C++**
- Windows 10/11 SDK
- x64 Toolchain
Die Release-Projekte verwenden die statische MSVC-Laufzeit.
### Vollständiges Release
```powershell
.\build-release.ps1 -Clean
```
Erwartete Ausgabe:
```text
dist\
├── PlandentMstscScriptHook.exe
├── PlandentRdpClientTrigger.exe
└── PlandentSiominClientTrigger.exe
```
Die Plugin-DLL wird beim Build in die Configurator-EXE eingebettet und muss nicht separat verteilt werden.
## 4. Komponenten
### 4.1 Configurator
`PlandentMstscScriptHook.exe` ist ein nativer Win32-Konfigurator.
Aufgaben:
- Lesen und Schreiben der HKCU-Konfiguration,
- Teststart der konfigurierten Programme,
- Extraktion der eingebetteten Plugin-DLL,
- Registrierung des Plugins für `mstsc.exe`,
- Entfernen der MSTSC-Registrierung,
- Zugriff auf das Plugin-Log.
Die Installation ist benutzerbezogen.
### 4.2 MSTSC-Plugin
`PlandentMstscScriptHook.dll` wird vom klassischen Microsoft-RDP-Client geladen.
Das Plugin übernimmt:
- RDP-Lifecycle,
- optionalen Start bei erfolgreichem Connect,
- DVC-Listener,
- Hostname-Abfrage,
- festen START-Trigger,
- Start des lokal vorkonfigurierten Programms,
- Logging und Mehrfachstartschutz.
### 4.3 Einfacher Server-Trigger
`PlandentRdpClientTrigger.exe` läuft innerhalb der aktuellen RDP-Sitzung und sendet ausschließlich den festen START-Befehl.
Es gibt absichtlich keine Kommandozeilenparameter für Zielpfad oder Argumente.
### 4.4 SIOMIN-Trigger
`PlandentSiominClientTrigger.exe` läuft ebenfalls innerhalb der RDP-Sitzung.
Der Ablauf besteht aus:
1. INI lesen oder erzeugen,
2. Client-Hostname über DVC abfragen,
3. temporäre SDX-Datei binär lesen,
4. Hostname im SIOMIN-Aktionsdatensatz ersetzen,
5. interne Datensatzlänge korrigieren,
6. Ergebnis an finale `siomin.sdx` anhängen,
7. Daten mit `FlushFileBuffers()` bestätigen,
8. temporäre SDX auf 0 Bytes kürzen,
9. START über einen neuen DVC senden,
10. START-Quittung abwarten.
## 5. Registry-Konfiguration
### MSTSC-Registrierung
```text
HKCU\Software\Microsoft\Terminal Server Client\Default\AddIns\PlandentMstscScriptHook
Name = %LOCALAPPDATA%\Plandent\MstscScriptHook\PlandentMstscScriptHook.dll
```
### Anwendungskonfiguration
```text
HKCU\Software\Plandent\MstscScriptHook
```
Relevante Werte:
```text
Enabled REG_DWORD
EnableLogging REG_DWORD
ScriptPath REG_EXPAND_SZ
Arguments REG_SZ
WorkingDirectory REG_EXPAND_SZ
StartOnConnect REG_DWORD
StopOnDisconnect REG_DWORD
Hidden REG_DWORD
PreventDuplicates REG_DWORD
TriggerEnabled REG_DWORD
TriggerProgramPath REG_EXPAND_SZ
TriggerArguments REG_SZ
TriggerWorkingDirectory REG_EXPAND_SZ
TriggerHidden REG_DWORD
TriggerPreventDuplicates REG_DWORD
```
Der automatische RDP-Start und der DVC-Trigger besitzen bewusst getrennte Konfigurationen.
## 6. Lokaler Prozessstart
Unterstützte Dateitypen:
```text
.exe
.bat
.cmd
.ps1
```
Für Batchdateien wird `cmd.exe`, für PowerShell-Scripte Windows PowerShell und für EXE-Dateien ein direkter Prozessstart verwendet.
Bei verborgenem Start werden die entsprechenden Windows-Prozessflags verwendet, damit kein Konsolenfenster sichtbar wird.
### Mehrfachstartschutz
Für den automatischen Start und den Triggerstart kann separat ein Mehrfachstartschutz aktiviert werden.
Aus dem expandierten Programmpfad wird ein Named Mutex abgeleitet. Das Mutex-Handle wird an den gestarteten Root-Prozess vererbt. Solange der Prozess lebt, kann derselbe konfigurierte Pfad nicht erneut gestartet werden.
## 7. Dynamic Virtual Channel
### Kanalname
```text
plandent::mstsc-script-hook
```
Der Name ist in Client-Plugin und Server-Komponenten identisch zu halten.
### Protokoll
Aktuell definierte Nachrichten:
```text
PLANDENT_MSTSC_HOOK/1 START
PLANDENT_MSTSC_HOOK/1 START_OK
PLANDENT_MSTSC_HOOK/1 START_FAILED
PLANDENT_MSTSC_HOOK/1 GET_CLIENT_HOSTNAME
PLANDENT_MSTSC_HOOK/1 CLIENT_HOSTNAME <hostname>
```
### START
Der Server öffnet einen kurzlebigen DVC für die aktuelle RDP-Sitzung und sendet `START`.
Das Plugin prüft die exakte Nachricht und startet ausschließlich das lokal konfigurierte Trigger-Programm.
Danach antwortet das Plugin mit:
```text
START_OK
```
oder:
```text
START_FAILED
```
Der Server schließt den DVC erst nach Empfang dieser Quittung. Diese Quittierung verhindert die zuvor beobachtete Race Condition, bei der ein unmittelbar nach `WTSVirtualChannelWrite()` geschlossener Kanal dazu führen konnte, dass spätere Trigger nicht mehr beim Client ankamen.
### Client-Hostname
Der SIOMIN-Trigger sendet zunächst:
```text
PLANDENT_MSTSC_HOOK/1 GET_CLIENT_HOSTNAME
```
Der Rechnername wird direkt auf dem lokalen Windows-Client ermittelt. Damit ist die Funktion nicht von `CLIENTNAME` oder `COMPUTERNAME` innerhalb der Remotesitzung abhängig.
Antwort:
```text
PLANDENT_MSTSC_HOOK/1 CLIENT_HOSTNAME FR-Empfang-3R
```
Für den anschließenden START wird ein neuer DVC geöffnet.
### DVC-Payload auf der Serverseite
Bei Antworten des Client-Plugins kann `WTSVirtualChannelRead()` einen `CHANNEL_PDU_HEADER` vor den eigentlichen Payload stellen.
`Shared/DvcServerIo.h` behandelt daher:
- DVC-Header,
- Payload-Länge,
- gegebenenfalls fragmentierte Nachrichten,
- Timeout.
Diese Behandlung muss für alle zukünftigen bidirektionalen DVC-Nachrichten wiederverwendet werden.
## 8. SIOMIN-/SLIDA-Dateiformat
### Wichtig: binäres bzw. längencodiertes Format
Die untersuchten SIOMIN-Dateien dürfen nicht wie gewöhnliche Textdateien neu serialisiert werden.
Das beobachtete Format verwendet:
- ein Byte für die Datensatzlänge,
- NUL-Trenner,
- einbyteige Textfelder,
- CRLF am Ende des Datensatzes.
Beobachtete Grundstruktur:
```text
[LEN] 00 [TYPE] 00 [Feld] 00 [Feld] 00 ... 0D 0A
```
`LEN` ist die **Gesamtlänge des jeweiligen Datensatzes in Bytes**, einschließlich Längenbyte und abschließendem CRLF.
Beispiele:
```text
3A 00 4E 00 ... -> 0x3A = 58 Byte, Typ N
40 00 4E 00 ... -> 0x40 = 64 Byte, Typ N
46 00 41 00 ... -> 0x46 = 70 Byte, Typ A
4C 00 41 00 ... -> 0x4C = 76 Byte, Typ A
```
Dadurch darf das erste Byte nicht als ASCII-Datensatzkennung interpretiert werden. Je nach Länge kann es im Texteditor zufällig als sichtbarer Buchstabe erscheinen.
### A-Datensatz
Für den relevanten Aktionsdatensatz wurde folgende Feldfolge beobachtet:
```text
LEN
00
A
00
Nachname
00
Vorname
00
Geburtsdatum
00
Patienten-ID
00
Hostname
00
Datum
00
Uhrzeit
...
0D 0A
```
Der Hostname beginnt nach dem sechsten NUL-Trenner ab Datensatzbeginn.
### Ersetzen des Hostnamens
Der Hostname wird als druckbares ASCII in das vorhandene Feld geschrieben.
Beispiel:
```text
TS-FR
```
wird zu:
```text
FR-Empfang-3R
```
Wichtig ist die Größenänderung:
```text
TS-FR = 5 Byte
FR-Empfang-3R = 13 Byte
Differenz = +8 Byte
```
Ein ursprünglicher A-Datensatz mit:
```text
0x4C = 76 Byte
```
muss anschließend:
```text
0x54 = 84 Byte
```
als Längenbyte enthalten.
Wird nur der Hostname geändert und das Längenbyte nicht angepasst, verwirft Sidexis den Datensatz. Ein real beobachteter Fehler lautete sinngemäß:
```text
specified length = 76
real length = 84
```
und führte zur Ablage in `unprocessable.sdx`.
### Parser-Regeln
Aktuelle Regeln:
- Datensatzbeginn ist die Position des Längenbytes.
- `recordStart + LEN` muss innerhalb der Quelldatei liegen.
- Datensatzende muss `0D 0A` enthalten.
- Nur Datensätze mit Typ `A` werden hinsichtlich Hostname verändert.
- Der Hostname darf nicht leer sein.
- Nach Änderung muss die neue Datensatzlänge zwischen 1 und 255 liegen.
- Alle nicht veränderten Bytes werden unverändert übernommen.
- Mehrere passende A-Datensätze können in einem Temp-Block verarbeitet werden.
## 9. SIOMIN-INI
Der SIOMIN-Trigger verwendet eine INI mit demselben Basisnamen wie die EXE.
Beispiel:
```text
PlandentSiominClientTrigger.exe
PlandentSiominClientTrigger.ini
```
Inhalt:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=C:\PDATA\siomin.sdx
DEBUG=0
```
Typische Terminalserver-Konfiguration:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=\\SERVER\PDATA\siomin.sdx
DEBUG=0
```
Fehlt die INI, wird sie als UTF-16-LE-Datei mit BOM erzeugt.
Fehlt bei einer bestehenden INI nur `DEBUG`, wird `DEBUG=0` ergänzt.
## 10. SIOMIN-Dateioperationen
### Temporäre Datei
`SIOMIN_TEMP_PATH` wird vollständig binär eingelesen.
Die Datei wird erst geleert, wenn:
- ein gültiger Hostname vorliegt,
- die Transformation erfolgreich war,
- mindestens ein A-Datensatz geändert wurde,
- die finale Datei erfolgreich geschrieben wurde,
- `FlushFileBuffers()` erfolgreich war.
Danach wird die Temp-Datei auf 0 Bytes gekürzt, nicht gelöscht.
### Finale Datei
Existiert `SIOMIN_PATH` noch nicht, wird sie angelegt.
Bei einer bereits vorhandenen, nichtleeren Datei wird geprüft, ob die Datei auf CRLF endet. Ist die Dateigrenze nicht sicher, wird nicht angehängt.
### Rollback
Kann die Temp-Datei nach erfolgreichem Append nicht geleert werden, versucht der Trigger das Append zurückzurollen:
- bei vorher nicht vorhandener finaler Datei: Datei löschen,
- bei vorhandener finaler Datei: auf ursprüngliche Größe zurückkürzen.
Damit soll verhindert werden, dass beim nächsten Aufruf derselbe Temp-Eintrag erneut angehängt wird.
## 11. SIOMIN-Logging
INI:
```ini
DEBUG=1
```
Logpfad:
```text
<EXE-Verzeichnis>\PlandentSiominClientTrigger.log
```
Das Log enthält unter anderem:
- EXE- und INI-Pfad,
- konfigurierte SIOMIN-Pfade,
- Hostname-Abfrage,
- DVC-Payload-Größen,
- ermittelten Client-Hostname,
- Größe der Temp-Datei,
- Anzahl der geänderten Hostfelder,
- Größe des transformierten Blocks,
- Append,
- Clear,
- START und START-Quittung.
Bei Parserfehlern kann zusätzlich eine Hex-Vorschau der Temp-SDX ausgegeben werden.
`DEBUG=0` deaktiviert die Logdatei, nicht aber `OutputDebugStringW`.
## 12. Exitcodes
### PlandentRdpClientTrigger.exe
```text
0 START_OK empfangen
10 DVC konnte nicht geöffnet werden
11 Schreiben fehlgeschlagen
12 Nachricht nicht vollständig geschrieben
13 START-Quittung konnte nicht gelesen werden
14 START_FAILED empfangen
15 ungültige START-Quittung
```
### PlandentSiominClientTrigger.exe
```text
0 erfolgreich
20 EXE-/INI-Pfad konnte nicht ermittelt werden
21 INI konnte nicht angelegt werden
22 DVC für Hostname konnte nicht geöffnet werden
23 Hostname-Anfrage konnte nicht geschrieben werden
24 Hostname-Antwort konnte nicht gelesen werden
25 ungültige Hostname-Antwort
26 Client-Hostname für SIOMIN ungeeignet
27 Temp-SDX konnte nicht gelesen werden
28 kein gültiges A-Hostname-Feld gefunden
29 SIOMIN-Pfade ungültig oder identisch
30 finale Datei konnte nicht geöffnet werden
31 unsichere Dateigrenze der finalen SIOMIN-Datei
32 finale Datei konnte nicht geschrieben werden
33 START konnte nicht geschrieben werden
34 Temp-SDX konnte nicht geleert werden
35 START-Quittung konnte nicht gelesen werden
36 START_FAILED vom Client
37 ungültige START-Quittung
38 Rollback nach Clear-Fehler fehlgeschlagen
```
## 13. Logging des Client-Plugins
Registry-Option:
```text
EnableLogging=1
```
Log:
```text
%LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log
```
Typische Einträge:
- `IWTSPlugin::Initialize`
- Registrierung des DVC-Listeners
- `Connected`
- `Disconnected`
- angenommener DVC
- `GET_CLIENT_HOSTNAME`
- `START`
- gestartete PID
- Kanal geschlossen
Bei DVC-Problemen sollten immer sowohl das Client-Plugin-Log als auch das Log der jeweiligen Server-Trigger-EXE betrachtet werden.
## 14. RemoteVDDS-Anwendungsfall
Der automatische RDP-Start ist insbesondere für die RemoteVDDS-Scripte von Tobias Bauer vorgesehen.
Typische lokale Konfiguration:
```text
ScriptPath=C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat
Arguments=MIN
WorkingDirectory=C:\RemoteVDDS
StartOnConnect=1
Hidden=1
PreventDuplicates=1
```
Die RemoteVDDS-Scripte selbst gehören nicht zum Projekt und sollten nicht ohne Prüfung ihrer eigenen Lizenzbedingungen in Releases aufgenommen werden.
## 15. Sidexis-Anwendungsfall
Auf dem Client wird Sidexis als Trigger-Programm konfiguriert.
Auf dem Terminalserver wird im aufrufenden Fachprogramm `PlandentSiominClientTrigger.exe` als Sidexis-EXE eingetragen.
Das Fachprogramm schreibt zunächst in eine lokale Temp-SDX. Der Trigger schreibt den korrigierten Eintrag anschließend in die zentrale PDATA-`siomin.sdx` und startet erst danach Sidexis auf dem tatsächlichen RDP-Client.
Wichtig ist, dass die Temp-Datei nicht direkt dieselbe Datei wie die finale `siomin.sdx` ist.
## 16. Sicherheit und Grenzen
Das DVC-Protokoll ist absichtlich keine allgemeine Remote-Execution-Schnittstelle.
Der Server kann nur:
- den Client-Hostname anfragen,
- den lokal vorkonfigurierten START auslösen.
Der Server überträgt keinen lokalen Programmpfad und keine Startparameter.
Weitere Grenzen:
- nur klassischer Microsoft-RDC-/MSTSC-Pluginmechanismus,
- x64-Build,
- Trigger muss innerhalb der betreffenden RDP-Sitzung laufen,
- lokaler Prozess läuft im Sicherheitskontext des MSTSC-Benutzers,
- AppLocker/WDAC können DLL-Laden oder Prozessstarts blockieren,
- SIOMIN-Parser basiert auf dem praktisch beobachteten, längencodierten Format und sollte bei anderen Sidexis-/SLIDA-Versionen mit echten Beispieldateien validiert werden.
## 17. Testempfehlung
Vor einem Release sollten mindestens folgende Fälle getestet werden:
### Client
- Plugin installieren/aktualisieren/entfernen
- neue MSTSC-Verbindung lädt Plugin
- automatischer Scriptstart
- unsichtbarer Batchstart
- Mehrfachstartschutz
- Disconnect-Verhalten
### Einfacher Trigger
- erster Start
- mehrere Starts innerhalb derselben RDP-Sitzung
- Start bei bereits laufendem Zielprogramm
- fehlendes Plugin
- `START_FAILED`
### SIOMIN
- INI-Neuanlage
- lokaler und UNC-Zielpfad
- Hostname kürzer/länger als vorhandener Hostname
- korrekte Anpassung des Längenbytes
- mehrere Datensätze in einer Temp-Datei
- finale Datei nicht vorhanden
- finale Datei bereits vorhanden
- Temp-Datei wird nach Erfolg geleert
- Rollback bei Clear-Fehler
- `DEBUG=1`
- Kontrolle, dass Sidexis den Eintrag verarbeitet und nicht nach `unprocessable.sdx` verschiebt
## 18. Lizenz und Drittkomponenten
Copyright (c) 2026 Patrick Gniza.
Der Projektcode ist proprietär. Siehe `LICENSE`.
Drittkomponenten und separat bezogene Scripte, insbesondere die RemoteVDDS-Scripte von Tobias Bauer, sind nicht automatisch Bestandteil dieser Lizenz.
+306
View File
@@ -0,0 +1,306 @@
# Plandent MSTSC Script Hook
## Zweck
Der **Plandent MSTSC Script Hook** wird eingesetzt, wenn Programme oder Scripte auf dem lokalen Arbeitsplatzrechner mit einer Anwendung innerhalb einer RDP-/Terminalserver-Sitzung zusammenarbeiten müssen.
Das Tool deckt drei typische Techniker-Szenarien ab:
- automatischer Start eines lokalen Receivers oder Scripts beim Aufbau einer RDP-Verbindung,
- gezielter Start eines vorkonfigurierten lokalen Programms aus der Terminalserver-Sitzung,
- Verbindung einer Anwendung im Terminalserver mit einem lokal installierten Sidexis über SIOMIN/SLIDA.
Das Tool ist für den klassischen Windows-RDP-Client **Remotedesktopverbindung (`mstsc.exe`)** vorgesehen.
---
## Benötigte Dateien
### Auf dem lokalen Arbeitsplatz
```text
PlandentMstscScriptHook.exe
```
Diese EXE enthält alles, was zur Installation und Konfiguration des Client-Plugins benötigt wird.
### Auf dem Terminalserver
Je nach Einsatzzweck:
```text
PlandentRdpClientTrigger.exe
```
für einen einfachen Programmstart auf dem Client,
oder:
```text
PlandentSiominClientTrigger.exe
```
für den Sidexis-/SIOMIN-Anwendungsfall.
---
## Installation auf dem Client
1. Alle laufenden RDP-Verbindungen können zunächst bestehen bleiben, müssen nach der Installation aber einmal vollständig geschlossen werden.
2. `PlandentMstscScriptHook.exe` auf dem lokalen Arbeitsplatz starten.
3. Die gewünschte Konfiguration vornehmen.
4. **Plugin installieren** anklicken.
5. Danach alle noch geöffneten `mstsc.exe`-Fenster vollständig schließen.
6. Die RDP-Verbindung neu starten.
Die Installation erfolgt für den aktuell angemeldeten Windows-Benutzer.
---
# Anwendungsfall 1: RemoteVDDS automatisch starten
In Verbindung mit den **RemoteVDDS-Scripten von Tobias Bauer** kann der benötigte Receiver automatisch gestartet werden, sobald der Techniker bzw. Anwender eine RDP-Verbindung zum Terminalserver aufbaut.
Im Bereich **Automatischer Start bei RDP-Verbindung**:
```text
Programm/Script:
C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat
Argumente:
MIN
Arbeitsverzeichnis:
C:\RemoteVDDS
```
Empfohlene Optionen:
```text
[x] Bei erfolgreicher RDP-Verbindung starten
[x] Unsichtbar starten
[x] Mehrfachstart verhindern
```
**Bei RDP-Trennung beenden** sollte nur aktiviert werden, wenn der Receiver tatsächlich mit dem Ende der RDP-Verbindung beendet werden soll.
Bei einer Datei mit `RemoteVDDS_Receiver_TS` im Namen wird `MIN` vom Konfigurator automatisch als Argument vorgeschlagen, sofern noch keine Argumente eingetragen sind.
Über **Programm testen** kann vor der Installation geprüft werden, ob das Script auf dem lokalen Arbeitsplatz korrekt startet.
> Die RemoteVDDS-Scripte selbst gehören nicht zum Plandent MSTSC Script Hook und müssen separat vorhanden sein.
---
# Anwendungsfall 2: Lokales Programm bei Bedarf starten
Soll ein lokales Programm nur dann gestartet werden, wenn eine Anwendung oder ein Ablauf innerhalb des Terminalservers dies benötigt, wird der zweite Bereich **Programmstart durch Terminalserver** verwendet.
Auf dem Client beispielsweise:
```text
Programm:
C:\Program Files\Sirona\Sidexis4\Sidexis4.exe
```
Optional können Argumente und ein Arbeitsverzeichnis eingetragen werden.
Empfohlene Einstellungen:
```text
[x] Start durch Terminalserver-Trigger erlauben
[x] Mehrfachstart verhindern
```
Ob **Unsichtbar starten** sinnvoll ist, hängt vom jeweiligen Programm ab. Eine grafische Anwendung wie Sidexis soll normalerweise sichtbar gestartet werden.
Auf dem Terminalserver wird anschließend innerhalb der jeweiligen Benutzersitzung lediglich:
```text
PlandentRdpClientTrigger.exe
```
ausgeführt.
Der Trigger startet immer das Programm, das vorher auf dem jeweiligen Client konfiguriert wurde.
---
# Anwendungsfall 3: Terminalprogramm mit lokalem Sidexis verbinden
Dieser Anwendungsfall ist für Programme gedacht, die **innerhalb der RDP-Sitzung laufen**, ihre Patientendaten aber an ein **lokal auf dem Arbeitsplatz installiertes Sidexis** übergeben sollen.
Dafür wird auf dem Terminalserver:
```text
PlandentSiominClientTrigger.exe
```
verwendet.
## Client konfigurieren
Im Bereich **Programmstart durch Terminalserver** wird auf dem lokalen Arbeitsplatz Sidexis eingetragen, beispielsweise:
```text
C:\Program Files\Sirona\Sidexis4\Sidexis4.exe
```
Anschließend:
```text
[x] Start durch Terminalserver-Trigger erlauben
[x] Mehrfachstart verhindern
```
und **Plugin installieren**.
## Terminalprogramm konfigurieren
Im Programm innerhalb der RDP-Sitzung wird als **Sidexis-EXE** bzw. als aufzurufendes Sidexis-Programm nicht die echte Sidexis-EXE eingetragen, sondern:
```text
C:\PDATA\exe\PlandentSiominClientTrigger.exe
```
Das Terminalprogramm muss seinen SLIDA-/SIOMIN-Eintrag zunächst in eine **temporäre lokale Datei auf dem Terminalserver** schreiben.
Empfehlung:
```text
C:\PDATA\temp_siomin.sdx
```
Die finale SIOMIN-Datei befindet sich üblicherweise im zentralen PDATA-Verzeichnis, zum Beispiel:
```text
\\SERVER\PDATA\siomin.sdx
```
## INI konfigurieren
Neben:
```text
PlandentSiominClientTrigger.exe
```
liegt:
```text
PlandentSiominClientTrigger.ini
```
Beispiel:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=\\SERVER\PDATA\siomin.sdx
DEBUG=0
```
Fehlt die INI, wird sie beim ersten Start automatisch mit folgenden Standardwerten erstellt:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=C:\PDATA\siomin.sdx
DEBUG=0
```
Danach muss `SIOMIN_PATH` gegebenenfalls auf das tatsächlich verwendete PDATA-Verzeichnis geändert werden.
## Was beim Aufruf passiert
Der Techniker muss dafür keine zusätzlichen Parameter angeben.
Beim Aufruf übernimmt der Trigger den Eintrag aus der temporären Datei, ordnet ihn dem lokalen RDP-Client zu und schreibt ihn in die finale `siomin.sdx`.
Nach erfolgreicher Übernahme wird die temporäre Datei geleert und anschließend das auf dem Client vorkonfigurierte Sidexis gestartet.
Damit kann ein Terminalprogramm den Übergabevorgang so verwenden, als würde es Sidexis starten, obwohl Sidexis tatsächlich auf dem lokalen Arbeitsplatz läuft.
---
# Fehlersuche
## Clientprogramm startet nicht
Prüfen:
- wurde das Plugin auf dem **lokalen Arbeitsplatz** installiert?
- wurden nach der Installation alle alten `mstsc.exe`-Instanzen beendet?
- ist **Start durch Terminalserver-Trigger erlauben** aktiviert?
- ist der hinterlegte Programmpfad korrekt?
- verhindert **Mehrfachstart verhindern** absichtlich einen zweiten Start, weil das Programm bereits läuft?
Das Client-Log befindet sich bei aktiviertem Plugin-Logging unter:
```text
%LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log
```
## SIOMIN-Übergabe funktioniert nicht
In:
```text
PlandentSiominClientTrigger.ini
```
temporär:
```ini
DEBUG=1
```
setzen.
Danach wird neben der EXE eine Logdatei erzeugt:
```text
PlandentSiominClientTrigger.log
```
Prüfen:
- existiert die unter `SIOMIN_TEMP_PATH` angegebene Datei?
- enthält sie nach dem Aufruf des Terminalprogramms einen Eintrag?
- ist `SIOMIN_PATH` der richtige PDATA-Pfad?
- besitzt der angemeldete Terminalserver-Benutzer Schreibrechte auf die finale `siomin.sdx`?
- ist auf dem lokalen Client der Terminalserver-Trigger aktiviert?
- wurde die RDP-Verbindung nach Installation oder Update des Plugins neu aufgebaut?
Wenn Sidexis einen Eintrag nach `unprocessable.sdx` verschiebt, sollten sowohl das SIOMIN-Trigger-Log als auch das Sidexis-Log zur weiteren Prüfung gesichert werden.
---
# Update des Client-Plugins
Bei einer neuen Version:
1. alle `mstsc.exe`-Instanzen auf dem Client schließen,
2. neue `PlandentMstscScriptHook.exe` starten,
3. **Plugin installieren** erneut ausführen,
4. RDP-Verbindung neu starten.
Die vorhandene Konfiguration wird dabei weiterverwendet.
---
# Deinstallation
`PlandentMstscScriptHook.exe` starten und **Plugin entfernen** wählen.
Sollte die Plugin-Datei noch verwendet werden, zuerst sämtliche `mstsc.exe`-Instanzen schließen und die Entfernung erneut durchführen.
---
## Kurzüberblick
| Aufgabe | Client | Terminalserver |
|---|---|---|
| RemoteVDDS automatisch starten | Automatischen Scriptstart konfigurieren | keine zusätzliche EXE erforderlich |
| Lokales Programm bei Bedarf starten | Trigger-Programm konfigurieren | `PlandentRdpClientTrigger.exe` |
| Terminalprogramm mit lokalem Sidexis verbinden | Sidexis als Trigger-Programm konfigurieren | `PlandentSiominClientTrigger.exe` + INI |
+37
View File
@@ -0,0 +1,37 @@
Copyright (c) 2026 Patrick Gniza
All rights reserved.
PROPRIETARY SOFTWARE LICENSE
This software, including its source code, binaries, documentation, build files,
configuration files, and other materials that are part of this project, is
proprietary software owned by Patrick Gniza unless explicitly stated otherwise.
Permission is granted only to persons or organizations that have received
explicit authorization from Patrick Gniza to use this software.
Without prior written permission from Patrick Gniza, you may not:
- publish, distribute, sublicense, sell, rent, lease, or otherwise make the
software or its source code available to third parties;
- create or distribute derivative works;
- remove or alter copyright, ownership, or license notices;
- use the software or source code in another product intended for distribution;
- make the repository or substantial parts of it publicly accessible.
Authorized users may install, execute, configure, and internally deploy the
software for the purposes for which access has been granted.
Third-party software, scripts, libraries, product names, and trademarks are not
covered by this license. In particular, separately obtained RemoteVDDS scripts
or other third-party components remain subject to the rights and license terms
of their respective authors and owners.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT
HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT
OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
For permissions beyond the scope of this license, written authorization from
Patrick Gniza is required.
+13 -7
View File
@@ -2,33 +2,39 @@
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Plugin", "Plugin\PlandentMstscScriptHook.vcxproj", "{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}" Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Configurator", "Configurator\Configurator.vcxproj", "{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlandentMstscScriptHook.Configurator", "Configurator\Configurator.csproj", "{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}"
ProjectSection(ProjectDependencies) = postProject ProjectSection(ProjectDependencies) = postProject
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} = {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} = {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}
EndProjectSection EndProjectSection
EndProject EndProject
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Plugin", "Plugin\PlandentMstscScriptHook.vcxproj", "{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}"
EndProject
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentRdpClientTrigger", "ServerTrigger\PlandentRdpClientTrigger.vcxproj", "{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}" Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentRdpClientTrigger", "ServerTrigger\PlandentRdpClientTrigger.vcxproj", "{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}"
EndProject EndProject
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentSiominClientTrigger", "SiominTrigger\PlandentSiominClientTrigger.vcxproj", "{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64 Debug|x64 = Debug|x64
Release|x64 = Release|x64 Release|x64 = Release|x64
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution 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.ActiveCfg = Debug|x64
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Debug|x64.Build.0 = 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.ActiveCfg = Release|x64
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.Build.0 = Release|x64 {935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.Build.0 = Release|x64
{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
{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Debug|x64.ActiveCfg = Debug|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}.Debug|x64.Build.0 = Debug|x64
{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.ActiveCfg = Release|x64 {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.ActiveCfg = Release|x64
{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.Build.0 = Release|x64 {A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.Build.0 = Release|x64
{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}.Debug|x64.ActiveCfg = Debug|x64
{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}.Debug|x64.Build.0 = Debug|x64
{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}.Release|x64.ActiveCfg = Release|x64
{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}.Release|x64.Build.0 = Release|x64
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+2
View File
@@ -53,6 +53,7 @@
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard> <LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
</ClCompile> </ClCompile>
<Link> <Link>
@@ -71,6 +72,7 @@
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard> <LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary> <RuntimeLibrary>MultiThreaded</RuntimeLibrary>
</ClCompile> </ClCompile>
<Link> <Link>
+171 -16
View File
@@ -10,10 +10,80 @@
namespace namespace
{ {
std::wstring GetLocalClientHostname()
{
// PhysicalDnsHostname liefert den Hostteil ohne DNS-Domaene und ist nicht von
// Variablen in der Remotesitzung abhaengig. Auf normalen Workstations entspricht
// das dem lokalen Rechnernamen. GetComputerNameW dient als Rueckfall.
wchar_t hostname[256]{};
DWORD hostnameChars = static_cast<DWORD>(sizeof(hostname) / sizeof(hostname[0]));
if (GetComputerNameExW(ComputerNamePhysicalDnsHostname, hostname, &hostnameChars))
{
return std::wstring(hostname, hostnameChars);
}
wchar_t fallback[MAX_COMPUTERNAME_LENGTH + 1]{};
DWORD fallbackChars = static_cast<DWORD>(sizeof(fallback) / sizeof(fallback[0]));
if (GetComputerNameW(fallback, &fallbackChars))
{
return std::wstring(fallback, fallbackChars);
}
return {};
}
std::string WideToUtf8(const std::wstring& value)
{
if (value.empty())
{
return {};
}
const int required = WideCharToMultiByte(
CP_UTF8,
WC_ERR_INVALID_CHARS,
value.data(),
static_cast<int>(value.size()),
nullptr,
0,
nullptr,
nullptr);
if (required <= 0)
{
return {};
}
std::string result(static_cast<std::size_t>(required), '\0');
const int written = WideCharToMultiByte(
CP_UTF8,
WC_ERR_INVALID_CHARS,
value.data(),
static_cast<int>(value.size()),
result.data(),
required,
nullptr,
nullptr);
if (written != required)
{
return {};
}
return result;
}
class TriggerChannelCallback final : public IWTSVirtualChannelCallback class TriggerChannelCallback final : public IWTSVirtualChannelCallback
{ {
public: public:
TriggerChannelCallback() = default; explicit TriggerChannelCallback(IWTSVirtualChannel* channel)
: channel_(channel)
{
if (channel_ != nullptr)
{
channel_->AddRef();
}
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override
{ {
@@ -57,32 +127,111 @@ namespace
return S_OK; return S_OK;
} }
if (cbSize == hook::protocol::kGetClientHostnameMessageLength &&
std::memcmp(buffer,
hook::protocol::kGetClientHostnameMessage,
hook::protocol::kGetClientHostnameMessageLength) == 0)
{
const std::wstring hostname = GetLocalClientHostname();
const std::string hostnameUtf8 = WideToUtf8(hostname);
if (channel_ == nullptr || hostnameUtf8.empty())
{
hook::Log(config.enableLogging,
L"DVC-Hostname: Lokaler Client-Hostname konnte nicht ermittelt oder beantwortet werden.");
return S_OK;
}
std::string response;
response.reserve(hook::protocol::kClientHostnameResponsePrefixLength + hostnameUtf8.size());
response.append(hook::protocol::kClientHostnameResponsePrefix,
hook::protocol::kClientHostnameResponsePrefixLength);
response.append(hostnameUtf8);
const HRESULT hr = channel_->Write(
static_cast<ULONG>(response.size()),
reinterpret_cast<BYTE*>(response.data()),
nullptr);
if (FAILED(hr))
{
hook::Log(config.enableLogging,
L"DVC-Hostname: Antwort konnte nicht geschrieben werden. HRESULT=" +
std::to_wstring(static_cast<unsigned long>(hr)) + L".");
}
else
{
hook::Log(config.enableLogging,
L"DVC-Hostname: Client-Hostname an Terminalserver beantwortet: " + hostname);
}
return S_OK;
}
if (cbSize != hook::protocol::kStartMessageLength || if (cbSize != hook::protocol::kStartMessageLength ||
std::memcmp(buffer, hook::protocol::kStartMessage, hook::protocol::kStartMessageLength) != 0) std::memcmp(buffer, hook::protocol::kStartMessage, hook::protocol::kStartMessageLength) != 0)
{ {
hook::Log(config.enableLogging, hook::Log(config.enableLogging,
L"DVC-Trigger: Unbekannte Nachricht empfangen; aus Sicherheitsgründen ignoriert."); L"DVC-Trigger: Unbekannte Nachricht empfangen; aus Sicherheitsgruenden ignoriert.");
return S_OK; return S_OK;
} }
hook::Log(config.enableLogging, L"DVC-Trigger: START vom Terminalserver empfangen."); hook::Log(config.enableLogging, L"DVC-Trigger: START vom Terminalserver empfangen.");
bool startSucceeded = false;
if (!config.enabled) if (!config.enabled)
{ {
hook::Log(config.enableLogging, L"DVC-Trigger: Plugin-Funktion ist deaktiviert; Start ignoriert."); hook::Log(config.enableLogging, L"DVC-Trigger: Plugin-Funktion ist deaktiviert; Start ignoriert.");
return S_OK;
} }
else if (!config.triggerEnabled)
if (!config.triggerEnabled)
{ {
hook::Log(config.enableLogging, L"DVC-Trigger: Server-Trigger ist in der Client-Konfiguration deaktiviert."); hook::Log(config.enableLogging, L"DVC-Trigger: Server-Trigger ist in der Client-Konfiguration deaktiviert.");
return S_OK; }
else
{
// Der Terminalserver liefert absichtlich weder Pfad noch Argumente.
// Beides wird ausschliesslich aus HKCU auf dem lokalen Client gelesen.
hook::ProcessLauncher launcher;
startSucceeded = launcher.Start(
config.triggerProgram,
config.enableLogging,
L"Server-Trigger-Programm");
}
// Der Server wartet auf diese Quittung, bevor er den kurzlebigen DVC
// schliesst. Das verhindert die beobachtete Race Condition, bei der
// WTSVirtualChannelWrite erfolgreich war, der Kanal aber geschlossen
// wurde, bevor OnDataReceived die START-Nachricht erhalten hatte.
if (channel_ != nullptr)
{
const char* response = startSucceeded
? hook::protocol::kStartOkResponse
: hook::protocol::kStartFailedResponse;
const std::size_t responseLength = startSucceeded
? hook::protocol::kStartOkResponseLength
: hook::protocol::kStartFailedResponseLength;
const HRESULT hr = channel_->Write(
static_cast<ULONG>(responseLength),
reinterpret_cast<BYTE*>(const_cast<char*>(response)),
nullptr);
if (FAILED(hr))
{
hook::Log(config.enableLogging,
L"DVC-Trigger: START-Quittung konnte nicht geschrieben werden. HRESULT=" +
std::to_wstring(static_cast<unsigned long>(hr)) + L".");
}
else
{
hook::Log(config.enableLogging,
startSucceeded
? L"DVC-Trigger: START_OK an Terminalserver gesendet."
: L"DVC-Trigger: START_FAILED an Terminalserver gesendet.");
}
} }
// 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; return S_OK;
} }
@@ -94,8 +243,17 @@ namespace
} }
private: private:
~TriggerChannelCallback() = default; ~TriggerChannelCallback()
{
if (channel_ != nullptr)
{
channel_->Release();
channel_ = nullptr;
}
}
LONG refCount_ = 1; LONG refCount_ = 1;
IWTSVirtualChannel* channel_ = nullptr;
}; };
} }
@@ -176,13 +334,11 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Initialize(IWTSVirtualChannelManager
L". Der automatische Start bei RDP-Verbindung bleibt trotzdem aktiv."); L". Der automatische Start bei RDP-Verbindung bleibt trotzdem aktiv.");
ReleaseDvcObjects(); ReleaseDvcObjects();
// Der DVC-Trigger ist eine optionale Zusatzfunktion. Ein nicht verfügbarer
// DVC darf den bestehenden Connected()/Disconnected()-Hook nicht deaktivieren.
return S_OK; return S_OK;
} }
hook::Log(config.enableLogging, hook::Log(config.enableLogging,
L"DVC-Listener für 'plandent::mstsc-script-hook' wurde registriert."); L"DVC-Listener fuer 'plandent::mstsc-script-hook' wurde registriert.");
return S_OK; return S_OK;
} }
@@ -197,7 +353,6 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Connected()
return S_OK; return S_OK;
} }
// Nur Registry lesen + CreateProcess aufrufen. Es wird niemals auf das Programm gewartet.
connectLauncher_.Start(config.connectProgram, config.enableLogging, L"RDP-Verbindungsprogramm"); connectLauncher_.Start(config.connectProgram, config.enableLogging, L"RDP-Verbindungsprogramm");
return S_OK; return S_OK;
} }
@@ -256,7 +411,7 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::OnNewChannelConnection(
return E_POINTER; return E_POINTER;
} }
auto* channelCallback = new (std::nothrow) TriggerChannelCallback(); auto* channelCallback = new (std::nothrow) TriggerChannelCallback(channel);
if (channelCallback == nullptr) if (channelCallback == nullptr)
{ {
hook::Log(config.enableLogging, L"DVC-Trigger: Callback konnte nicht angelegt werden."); hook::Log(config.enableLogging, L"DVC-Trigger: Callback konnte nicht angelegt werden.");
+195 -244
View File
@@ -1,283 +1,234 @@
# Plandent MSTSC Script Hook # Plandent MSTSC Script Hook
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: Native Windows-x64-Lösung zur Verbindung von lokal auf einem RDP-Client installierten Programmen mit Anwendungen und Abläufen innerhalb einer Terminalserver-Sitzung.
1. **Automatisch beim erfolgreichen RDP-Verbindungsaufbau**. Das Projekt ist insbesondere für drei praktische Einsatzfälle vorgesehen:
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. 1. **RemoteVDDS-Receiver beim Aufbau einer RDP-Verbindung automatisch lokal starten.**
2. **Ein fest vorkonfiguriertes lokales Programm bei Bedarf aus der RDP-Sitzung starten.**
3. **Eine Anwendung innerhalb der RDP-Sitzung über SIOMIN/SLIDA mit einem lokal installierten Sidexis verbinden.**
Die Lösung ist für den klassischen Microsoft Remote Desktop Connection Client `mstsc.exe` ausgelegt.
## Typischer Einsatzzweck
In einer Terminalserver-Umgebung befinden sich Praxissoftware oder andere Fachanwendungen innerhalb der RDP-Sitzung, während bestimmte Programme auf dem lokalen Arbeitsplatzrechner ausgeführt werden müssen.
Der Plandent MSTSC Script Hook stellt dafür eine kontrollierte Verbindung zwischen Terminalserver-Sitzung und lokalem Client bereit. Programme und Parameter werden ausschließlich auf dem Client vorkonfiguriert. Eine Anwendung auf dem Terminalserver kann keinen beliebigen lokalen Programmpfad übergeben.
## Komponenten ## Komponenten
### Client ### Lokaler RDP-Client
- **PlandentMstscScriptHook.dll** – native x64 DVC-Plugin-DLL, die von `mstsc.exe` geladen wird. **`PlandentMstscScriptHook.exe`**
- **PlandentMstscScriptHook.exe** – WinForms-Konfigurator/Installer. Die Release-EXE enthält die DLL als Resource und extrahiert sie bei der Installation.
Konfiguration und Installation des Client-Plugins. Hier werden zwei voneinander unabhängige Starts eingerichtet:
- automatischer Programm-/Scriptstart nach erfolgreichem RDP-Verbindungsaufbau,
- Programm-/Scriptstart auf Anforderung aus der Terminalserver-Sitzung.
Die benötigte Plugin-DLL ist in der EXE eingebettet und wird bei der Installation automatisch eingerichtet.
### Terminalserver ### Terminalserver
- **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. **`PlandentRdpClientTrigger.exe`**
## Architektur Löst innerhalb der aktuellen RDP-Sitzung den auf dem Client vorkonfigurierten Programmstart aus.
Die EXE benötigt keine Parameter. Welches Programm gestartet wird, ist ausschließlich auf dem lokalen Client hinterlegt.
**`PlandentSiominClientTrigger.exe`**
Spezialisierter Trigger zur Anbindung einer Anwendung innerhalb der RDP-Sitzung an ein lokal installiertes Sidexis.
Der Trigger:
- ermittelt den tatsächlichen Hostnamen des lokalen RDP-Clients,
- übernimmt einen temporär erzeugten SIOMIN-/SLIDA-Eintrag,
- ersetzt darin den Terminalservernamen durch den Hostnamen des lokalen Clients,
- schreibt den angepassten Eintrag in die finale `siomin.sdx`,
- leert anschließend die temporäre Datei,
- startet danach das auf dem Client konfigurierte Programm.
## RemoteVDDS
Ein vorgesehener Anwendungsfall ist die Verwendung mit den **RemoteVDDS-Scripten von Tobias Bauer**.
Der RemoteVDDS-Receiver kann über den Bereich **Automatischer Start bei RDP-Verbindung** auf dem Client eingetragen werden. Dadurch wird der Receiver beim Aufbau der Terminalserver-Verbindung automatisch und auf Wunsch unsichtbar im Hintergrund gestartet.
Beispiel:
```text ```text
LOKALER CLIENT TERMINALSERVER Programm/Script:
================ ============== C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat
mstsc.exe Argumente:
│ MIN
└── 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: Unsichtbar starten:
HKCU\Software\Plandent\MstscScriptHook Ja
TriggerProgramPath
TriggerArguments Mehrfachstart verhindern:
TriggerWorkingDirectory Ja
``` ```
## Sicherheit des Triggers Bei Auswahl einer Datei mit `RemoteVDDS_Receiver_TS` im Namen schlägt der Konfigurator bei leeren Argumenten automatisch `MIN` vor.
Die Server-Komponente ist absichtlich minimal gehalten: Die RemoteVDDS-Scripte selbst sind **nicht Bestandteil dieses Projekts** und unterliegen den Rechten ihres jeweiligen Autors.
- keine Kommandozeilenparameter für das Client-Programm, ## Programmstart aus der RDP-Sitzung
- 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. Soll ein lokales Programm nicht bei jeder RDP-Verbindung, sondern nur bei Bedarf gestartet werden, wird es auf dem Client im Bereich **Programmstart durch Terminalserver** konfiguriert.
## Registry Innerhalb der RDP-Sitzung wird anschließend lediglich ausgeführt:
MSTSC-Registrierung pro Benutzer:
```text
HKCU\Software\Microsoft\Terminal Server Client\Default\AddIns\PlandentMstscScriptHook
Name = C:\Users\<Benutzer>\AppData\Local\Plandent\MstscScriptHook\PlandentMstscScriptHook.dll
```
Konfiguration:
```text
HKCU\Software\Plandent\MstscScriptHook
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
```
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 aktivierter Option **Unsichtbar starten** werden Konsolenprogramme ohne sichtbares Konsolenfenster gestartet.
## Mehrfachstartschutz
Für beide Programme existiert eine getrennt konfigurierbare Option **Mehrfachstart verhindern**.
Der Schutz wird aus dem expandierten Programmpfad abgeleitet und verwendet einen Named Mutex unter:
```text
Local\Plandent.MstscScriptHook.<Hash>
```
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
Bei aktiviertem Logging:
```text
%LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log
```
Dort werden sowohl der normale RDP-Lifecycle als auch DVC-Verbindungen und Triggerstarts protokolliert.
## Voraussetzungen zum Bauen
- 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:
```powershell
.\build-release.ps1 -Clean
```
Ergebnis:
```text
dist\
├── PlandentMstscScriptHook.exe
└── PlandentRdpClientTrigger.exe
```
- `PlandentMstscScriptHook.exe` → auf dem **lokalen RDP-Client** verwenden.
- `PlandentRdpClientTrigger.exe` → auf den **Terminalserver** kopieren.
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. 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 vollständig schließen.
8. MSTSC neu starten und die RDP-Verbindung herstellen.
Die Client-Installation erfolgt unter `HKCU` und `%LOCALAPPDATA%`; dafür sind normalerweise keine Administratorrechte nötig.
## Installation auf dem Terminalserver
Es ist keine Registrierung erforderlich. Lediglich:
```text ```text
PlandentRdpClientTrigger.exe PlandentRdpClientTrigger.exe
``` ```
an einen geeigneten Ort kopieren und von der Anwendung ausführen, die den lokalen Start anfordern soll. Der Trigger startet das auf dem Client hinterlegte Programm. Programmpfad und Parameter werden nicht vom Terminalserver übertragen.
Beispiel aus einer Batchdatei innerhalb der RDP-Sitzung: ## Sidexis über SIOMIN aus einer RDP-Sitzung ansprechen
```bat Für Programme, die innerhalb der Terminalserver-Sitzung laufen, aber ein **lokal installiertes Sidexis** ansprechen sollen, steht `PlandentSiominClientTrigger.exe` zur Verfügung.
C:\Tools\PlandentRdpClientTrigger.exe
Im betreffenden Terminalprogramm wird als Sidexis-Programm bzw. Sidexis-EXE nicht direkt Sidexis, sondern beispielsweise folgende Datei hinterlegt:
```text
C:\PDATA\exe\PlandentSiominClientTrigger.exe
``` ```
Beispiel PowerShell mit Exitcode-Prüfung: Das Terminalprogramm sollte seinen SLIDA-/SIOMIN-Eintrag zunächst in eine **lokale temporäre Datei auf dem Terminalserver** schreiben, beispielsweise:
```text
C:\PDATA\temp_siomin.sdx
```
Der SIOMIN-Trigger übernimmt diesen Eintrag anschließend in die finale `siomin.sdx`, die typischerweise im gemeinsamen PDATA-Verzeichnis liegt:
```text
\\SERVER\PDATA\siomin.sdx
```
Die Konfiguration erfolgt über eine INI-Datei mit demselben Basisnamen wie die EXE:
```text
PlandentSiominClientTrigger.exe
PlandentSiominClientTrigger.ini
```
Beispiel:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=\\SERVER\PDATA\siomin.sdx
DEBUG=0
```
Existiert die INI beim ersten Start nicht, wird sie mit lokalen Standardpfaden automatisch erzeugt:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=C:\PDATA\siomin.sdx
DEBUG=0
```
Für die Fehlersuche kann `DEBUG=1` gesetzt werden. Dann wird neben der EXE eine gleichnamige `.log`-Datei erzeugt.
## Installation auf dem Client
1. `PlandentMstscScriptHook.exe` starten.
2. Gewünschten automatischen Start konfigurieren oder deaktivieren.
3. Optional ein separates Programm für den Terminalserver-Trigger konfigurieren.
4. Bei Bedarf **Start durch Terminalserver-Trigger erlauben** aktivieren.
5. Einstellungen speichern bzw. **Plugin installieren** wählen.
6. Bereits laufende `mstsc.exe`-Instanzen vollständig schließen.
7. RDP-Verbindung neu aufbauen.
Die Installation erfolgt benutzerbezogen und benötigt im Normalfall keine Administratorrechte.
## Bereitstellung auf dem Terminalserver
Je nach Anwendungsfall werden nur die benötigten EXE-Dateien auf den Terminalserver kopiert:
```text
PlandentRdpClientTrigger.exe
PlandentSiominClientTrigger.exe
```
Es ist dort keine Installation oder Registrierung erforderlich.
## Unterstützte lokale Programme
Der Client kann folgende Typen starten:
- `.exe`
- `.bat`
- `.cmd`
- `.ps1`
Programme können sichtbar oder unsichtbar gestartet werden. Optional kann ein Mehrfachstart verhindert werden.
## Logging
Das Client-Plugin kann ein Log unter folgendem Pfad führen:
```text
%LOCALAPPDATA%\Plandent\MstscScriptHook\plugin.log
```
Für den SIOMIN-Trigger kann separat über
```ini
DEBUG=1
```
eine Logdatei neben `PlandentSiominClientTrigger.exe` aktiviert werden.
## Build
Voraussetzungen:
- Windows 10/11 x64
- Visual Studio 2022 oder Build Tools
- Workload **Desktopentwicklung mit C++**
- Windows 10/11 SDK
Release erstellen:
```powershell ```powershell
& 'C:\Tools\PlandentRdpClientTrigger.exe' .\build-release.ps1 -Clean
if ($LASTEXITCODE -ne 0) {
Write-Warning "Client-Trigger fehlgeschlagen. ExitCode: $LASTEXITCODE"
}
``` ```
## Deinstallation auf dem Client Ausgabe:
**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. ```text
dist\
├── PlandentMstscScriptHook.exe
├── PlandentRdpClientTrigger.exe
└── PlandentSiominClientTrigger.exe
```
Die eigentliche Programmkonfiguration unter `HKCU\Software\Plandent\MstscScriptHook` bleibt absichtlich erhalten. Alle Komponenten sind native x64-Binaries. Eine .NET-Runtime wird nicht benötigt.
## Wichtige Grenzen ## Dokumentation
- Das Plugin ist für den klassischen Microsoft-RDC-Pluginmechanismus ausgelegt. Andere RDP-Clients müssen diesen Mechanismus unterstützen. - `Home.md` – Anwender-/Technikerdokumentation
- Der Server-Trigger muss innerhalb einer aktiven RDP-Sitzung ausgeführt werden, wenn `WTS_CURRENT_SESSION` verwendet wird. - `DEVELOPMENT.md` – technische Entwicklerdokumentation
- 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. ## Hinweise
- `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. Das Projekt ist für den klassischen Microsoft-RDP-Client `mstsc.exe` vorgesehen. Der Trigger muss innerhalb der RDP-Sitzung ausgeführt werden, deren lokaler Client angesprochen werden soll.
Der lokale Programmstart erfolgt mit den Rechten des Benutzers, unter dem `mstsc.exe` auf dem Client ausgeführt wird.
Produktnamen wie Sidexis sind Eigentum der jeweiligen Rechteinhaber. Die RemoteVDDS-Scripte von Tobias Bauer sind nicht Bestandteil dieses Projekts.
## Lizenz
Copyright (c) 2026 Patrick Gniza.
All rights reserved.
Siehe [LICENSE](LICENSE).
+56 -10
View File
@@ -1,33 +1,45 @@
#include "../Shared/TriggerProtocol.h" #include "../Shared/TriggerProtocol.h"
#include "../Shared/DvcServerIo.h"
#include <windows.h> #include <windows.h>
#include <wtsapi32.h> #include <wtsapi32.h>
#include <cstring>
#include <cwchar> #include <cwchar>
#include <stdlib.h> #include <vector>
namespace namespace
{ {
constexpr DWORD kAckTimeoutMs = 5000;
enum class ExitCode : int enum class ExitCode : int
{ {
Success = 0, Success = 0,
ChannelOpenFailed = 10, ChannelOpenFailed = 10,
WriteFailed = 11, WriteFailed = 11,
PartialWrite = 12 PartialWrite = 12,
AckReadFailed = 13,
StartRejected = 14,
InvalidAck = 15
}; };
void DebugError(const wchar_t* step, DWORD error) void DebugError(const wchar_t* step, DWORD error)
{ {
wchar_t buffer[256]{}; wchar_t buffer[256]{};
swprintf_s(buffer, _countof(buffer), L"PlandentRdpClientTrigger: %s fehlgeschlagen, Win32=%lu\n", step, error); swprintf_s(
buffer,
(sizeof(buffer) / sizeof(buffer[0])),
L"PlandentRdpClientTrigger: %s fehlgeschlagen, Win32=%lu\n",
step,
error);
OutputDebugStringW(buffer); OutputDebugStringW(buffer);
} }
} }
int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{ {
// WTS_CURRENT_SESSION stellt sicher, dass ausschließlich der Client der // WTS_CURRENT_SESSION stellt sicher, dass ausschliesslich der Client der
// RDP-Sitzung angesprochen wird, in der diese EXE ausgeführt wird. // RDP-Sitzung angesprochen wird, in der diese EXE ausgefuehrt wird.
HANDLE channel = WTSVirtualChannelOpenEx( HANDLE channel = WTSVirtualChannelOpenEx(
WTS_CURRENT_SESSION, WTS_CURRENT_SESSION,
const_cast<LPSTR>(hook::protocol::kChannelName), const_cast<LPSTR>(hook::protocol::kChannelName),
@@ -40,26 +52,60 @@ int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
} }
ULONG bytesWritten = 0; ULONG bytesWritten = 0;
BOOL written = WTSVirtualChannelWrite( const BOOL written = WTSVirtualChannelWrite(
channel, channel,
const_cast<PCHAR>(hook::protocol::kStartMessage), const_cast<PCHAR>(hook::protocol::kStartMessage),
static_cast<ULONG>(hook::protocol::kStartMessageLength), static_cast<ULONG>(hook::protocol::kStartMessageLength),
&bytesWritten); &bytesWritten);
DWORD writeError = written ? ERROR_SUCCESS : GetLastError();
WTSVirtualChannelClose(channel);
if (!written) if (!written)
{ {
DebugError(L"WTSVirtualChannelWrite", writeError); const DWORD error = GetLastError();
WTSVirtualChannelClose(channel);
DebugError(L"WTSVirtualChannelWrite", error);
return static_cast<int>(ExitCode::WriteFailed); return static_cast<int>(ExitCode::WriteFailed);
} }
if (bytesWritten != static_cast<ULONG>(hook::protocol::kStartMessageLength)) if (bytesWritten != static_cast<ULONG>(hook::protocol::kStartMessageLength))
{ {
WTSVirtualChannelClose(channel);
OutputDebugStringW(L"PlandentRdpClientTrigger: Trigger-Nachricht wurde nur teilweise geschrieben.\n"); OutputDebugStringW(L"PlandentRdpClientTrigger: Trigger-Nachricht wurde nur teilweise geschrieben.\n");
return static_cast<int>(ExitCode::PartialWrite); return static_cast<int>(ExitCode::PartialWrite);
} }
// Wichtig: den DVC nicht direkt nach WTSVirtualChannelWrite schliessen.
// Der Client quittiert START erst nach Verarbeitung. Dadurch bleibt der
// Kanal lange genug bestehen, bis OnDataReceived die Nachricht erhalten hat.
std::vector<char> response;
DWORD readError = ERROR_SUCCESS;
const bool readOk = hook::dvc::ReadMessage(channel, kAckTimeoutMs, response, readError);
WTSVirtualChannelClose(channel);
if (!readOk)
{
DebugError(L"START-Quittung lesen", readError);
return static_cast<int>(ExitCode::AckReadFailed);
}
if (response.size() == hook::protocol::kStartOkResponseLength &&
std::memcmp(
response.data(),
hook::protocol::kStartOkResponse,
hook::protocol::kStartOkResponseLength) == 0)
{
return static_cast<int>(ExitCode::Success); return static_cast<int>(ExitCode::Success);
}
if (response.size() == hook::protocol::kStartFailedResponseLength &&
std::memcmp(
response.data(),
hook::protocol::kStartFailedResponse,
hook::protocol::kStartFailedResponseLength) == 0)
{
OutputDebugStringW(L"PlandentRdpClientTrigger: Client hat den Programmstart abgelehnt/als fehlgeschlagen gemeldet.\n");
return static_cast<int>(ExitCode::StartRejected);
}
OutputDebugStringW(L"PlandentRdpClientTrigger: Ungueltige START-Quittung empfangen.\n");
return static_cast<int>(ExitCode::InvalidAck);
} }
@@ -53,6 +53,7 @@
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard> <LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
</ClCompile> </ClCompile>
<Link> <Link>
@@ -70,6 +71,7 @@
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard> <LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary> <RuntimeLibrary>MultiThreaded</RuntimeLibrary>
</ClCompile> </ClCompile>
<Link> <Link>
@@ -85,6 +87,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ClInclude Include="..\Shared\TriggerProtocol.h" /> <ClInclude Include="..\Shared\TriggerProtocol.h" />
<ClInclude Include="..\Shared\DvcServerIo.h" />
</ItemGroup> </ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets" /> <ImportGroup Label="ExtensionTargets" />
@@ -9,5 +9,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ClInclude Include="..\Shared\TriggerProtocol.h"><Filter>Header Files</Filter></ClInclude> <ClInclude Include="..\Shared\TriggerProtocol.h"><Filter>Header Files</Filter></ClInclude>
<ClInclude Include="..\Shared\DvcServerIo.h"><Filter>Header Files</Filter></ClInclude>
</ItemGroup> </ItemGroup>
</Project> </Project>
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include <windows.h>
#include <wtsapi32.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace hook::dvc
{
// Antworten, die das Client-Plugin ueber IWTSVirtualChannel::Write sendet,
// werden auf der Serverseite bei WTSVirtualChannelRead bei DVCs mit einem
// CHANNEL_PDU_HEADER (UINT32 length + UINT32 flags) geliefert. Fuer kleine
// Nachrichten kommt normalerweise genau ein PDU; die Funktion kann aber auch
// mehrere Chunks zusammensetzen.
inline bool ReadMessage(
HANDLE channel,
DWORD timeoutMs,
std::vector<char>& payload,
DWORD& win32Error)
{
payload.clear();
win32Error = ERROR_SUCCESS;
constexpr std::size_t kHeaderSize = sizeof(std::uint32_t) * 2;
constexpr std::uint32_t kFlagFirst = 0x00000001u;
constexpr std::uint32_t kFlagLast = 0x00000002u;
constexpr std::size_t kMaxMessageSize = 1024 * 1024;
bool firstRead = true;
std::uint32_t expectedLength = 0;
for (;;)
{
char buffer[4096]{};
ULONG bytesRead = 0;
const BOOL ok = WTSVirtualChannelRead(
channel,
timeoutMs,
buffer,
static_cast<ULONG>(sizeof(buffer)),
&bytesRead);
if (!ok)
{
win32Error = GetLastError();
return false;
}
if (bytesRead == 0)
{
win32Error = ERROR_NO_DATA;
return false;
}
// Kompatibilitaets-Fallback fuer Implementierungen, die das Payload
// ohne CHANNEL_PDU_HEADER zurueckgeben. Unsere Protokollnachrichten
// beginnen immer mit "PLANDENT_".
if (firstRead &&
bytesRead >= 9 &&
std::memcmp(buffer, "PLANDENT_", 9) == 0)
{
payload.assign(buffer, buffer + bytesRead);
return true;
}
if (bytesRead < kHeaderSize)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
std::uint32_t length = 0;
std::uint32_t flags = 0;
std::memcpy(&length, buffer, sizeof(length));
std::memcpy(&flags, buffer + sizeof(length), sizeof(flags));
const std::size_t chunkPayload = bytesRead - kHeaderSize;
if (length > kMaxMessageSize || chunkPayload > length)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
if (firstRead)
{
expectedLength = length;
if ((flags & kFlagFirst) == 0)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
payload.reserve(expectedLength);
firstRead = false;
}
else if (length != expectedLength)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
payload.insert(
payload.end(),
buffer + static_cast<std::ptrdiff_t>(kHeaderSize),
buffer + bytesRead);
if (payload.size() > expectedLength)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
if ((flags & kFlagLast) != 0)
{
if (payload.size() != expectedLength)
{
win32Error = ERROR_INVALID_DATA;
return false;
}
return true;
}
// Nach dem ersten Chunk gilt das Timeout erneut. Unsere Nachrichten
// sind sehr klein; dieser Pfad ist nur fuer Vollstaendigkeit vorhanden.
timeoutMs = std::max<DWORD>(timeoutMs, 1000);
}
}
}
+18 -1
View File
@@ -5,11 +5,28 @@
namespace hook::protocol namespace hook::protocol
{ {
// Der Dynamic-Virtual-Channel-Name muss auf Client und Terminalserver identisch sein. // 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"; inline constexpr char kChannelName[] = "plandent::mstsc-script-hook";
// Absichtlich ein festes Protokollkommando ohne Pfad/Argumente. // Absichtlich ein festes Protokollkommando ohne Pfad/Argumente.
// Der Terminalserver kann damit nur den lokal vorkonfigurierten Start anfordern. // Der Terminalserver kann damit nur den lokal vorkonfigurierten Start anfordern.
inline constexpr char kStartMessage[] = "PLANDENT_MSTSC_HOOK/1 START"; inline constexpr char kStartMessage[] = "PLANDENT_MSTSC_HOOK/1 START";
inline constexpr std::size_t kStartMessageLength = sizeof(kStartMessage) - 1; inline constexpr std::size_t kStartMessageLength = sizeof(kStartMessage) - 1;
// START wird quittiert. Die Server-EXE wartet auf diese Antwort, bevor sie den
// DVC schliesst. Dadurch geht die START-Nachricht nicht durch ein zu fruehes
// Schliessen des kurzlebigen Kanals verloren.
inline constexpr char kStartOkResponse[] = "PLANDENT_MSTSC_HOOK/1 START_OK";
inline constexpr std::size_t kStartOkResponseLength = sizeof(kStartOkResponse) - 1;
inline constexpr char kStartFailedResponse[] = "PLANDENT_MSTSC_HOOK/1 START_FAILED";
inline constexpr std::size_t kStartFailedResponseLength = sizeof(kStartFailedResponse) - 1;
// Abfrage des tatsaechlichen Hostnamens des lokalen RDP-Clients.
// Der Name wird direkt auf dem Client vom Plugin ermittelt und nicht aus
// CLIENTNAME/COMPUTERNAME der Remotesitzung uebernommen.
inline constexpr char kGetClientHostnameMessage[] = "PLANDENT_MSTSC_HOOK/1 GET_CLIENT_HOSTNAME";
inline constexpr std::size_t kGetClientHostnameMessageLength = sizeof(kGetClientHostnameMessage) - 1;
inline constexpr char kClientHostnameResponsePrefix[] = "PLANDENT_MSTSC_HOOK/1 CLIENT_HOSTNAME ";
inline constexpr std::size_t kClientHostnameResponsePrefixLength = sizeof(kClientHostnameResponsePrefix) - 1;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}</ProjectGuid>
<RootNamespace>PlandentSiominClientTrigger</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings" />
<ImportGroup Label="Shared" />
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<OutDir>$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\</OutDir>
<IntDir>$(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\SiominTrigger\</IntDir>
<TargetName>PlandentSiominClientTrigger</TargetName>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>Wtsapi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>false</GenerateDebugInformation>
<AdditionalDependencies>Wtsapi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="Main.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\Shared\TriggerProtocol.h" />
<ClInclude Include="..\Shared\DvcServerIo.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets" />
</Project>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{63E7B94A-4947-4D12-AE99-D6C33739AE76}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{87F56E92-224D-40FD-805D-FE0BC91D9C56}</UniqueIdentifier>
<Extensions>h;hpp;hxx</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="Main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\Shared\TriggerProtocol.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\Shared\DvcServerIo.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
</Project>
+23 -41
View File
@@ -7,11 +7,10 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSCommandPath $Root = Split-Path -Parent $PSCommandPath
$PluginProject = Join-Path $Root 'Plugin\PlandentMstscScriptHook.vcxproj' $Solution = Join-Path $Root 'PlandentMstscScriptHook.sln'
$ServerTriggerProject = Join-Path $Root 'ServerTrigger\PlandentRdpClientTrigger.vcxproj'
$ConfiguratorProject = Join-Path $Root 'Configurator\Configurator.csproj'
$Dist = Join-Path $Root 'dist' $Dist = Join-Path $Root 'dist'
$Artifacts = Join-Path $Root 'artifacts' $Artifacts = Join-Path $Root 'artifacts'
$GeneratedPayload = Join-Path $Root 'Configurator\Generated'
function Find-MSBuild { function Find-MSBuild {
$cmd = Get-Command msbuild.exe -ErrorAction SilentlyContinue $cmd = Get-Command msbuild.exe -ErrorAction SilentlyContinue
@@ -31,58 +30,41 @@ function Find-MSBuild {
if ($Clean) { if ($Clean) {
Remove-Item $Artifacts -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $Artifacts -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $Dist -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $Dist -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $GeneratedPayload -Recurse -Force -ErrorAction SilentlyContinue
} }
New-Item -ItemType Directory -Path $Dist -Force | Out-Null New-Item -ItemType Directory -Path $Dist -Force | Out-Null
$MSBuild = Find-MSBuild $MSBuild = Find-MSBuild
Write-Host "[1/3] Native MSTSC Plugin-DLL bauen ($Configuration|x64)..." -ForegroundColor Cyan Write-Host "[1/2] Native x64-Projektmappe neu bauen ($Configuration|x64)..." -ForegroundColor Cyan
& $MSBuild $PluginProject /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64' & $MSBuild $Solution /t:Rebuild /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64'
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Native DLL Build fehlgeschlagen (ExitCode $LASTEXITCODE)." throw "Build fehlgeschlagen (ExitCode $LASTEXITCODE)."
} }
$PluginDll = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.dll" $PluginDll = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.dll"
if (-not (Test-Path $PluginDll)) { $ConfiguratorExe = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.exe"
throw "Erwartete DLL wurde nicht erzeugt: $PluginDll"
}
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" $ServerTriggerExe = Join-Path $Artifacts "x64\$Configuration\PlandentRdpClientTrigger.exe"
if (-not (Test-Path $ServerTriggerExe)) { $SiominTriggerExe = Join-Path $Artifacts "x64\$Configuration\PlandentSiominClientTrigger.exe"
throw "Erwartete Server-Trigger-EXE wurde nicht erzeugt: $ServerTriggerExe"
foreach ($file in @($PluginDll, $ConfiguratorExe, $ServerTriggerExe, $SiominTriggerExe)) {
if (-not (Test-Path $file)) {
throw "Erwartete Build-Datei wurde nicht erzeugt: $file"
}
} }
Write-Host '[2/2] Verteilbare Dateien nach dist kopieren...' -ForegroundColor Cyan
Copy-Item $ConfiguratorExe (Join-Path $Dist 'PlandentMstscScriptHook.exe') -Force
Copy-Item $ServerTriggerExe (Join-Path $Dist 'PlandentRdpClientTrigger.exe') -Force Copy-Item $ServerTriggerExe (Join-Path $Dist 'PlandentRdpClientTrigger.exe') -Force
Copy-Item $SiominTriggerExe (Join-Path $Dist 'PlandentSiominClientTrigger.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 `
--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)."
}
$ConfiguratorExe = Join-Path $Dist 'PlandentMstscScriptHook.exe'
if (-not (Test-Path $ConfiguratorExe)) {
throw "Erwartete Konfigurations-EXE wurde nicht erzeugt: $ConfiguratorExe"
}
Write-Host '' Write-Host ''
Write-Host 'Build erfolgreich:' -ForegroundColor Green Write-Host 'Build erfolgreich:' -ForegroundColor Green
Write-Host " Client: $ConfiguratorExe" Write-Host " Client: $(Join-Path $Dist 'PlandentMstscScriptHook.exe')"
Write-Host " Server: $(Join-Path $Dist 'PlandentRdpClientTrigger.exe')" Write-Host " Server: $(Join-Path $Dist 'PlandentRdpClientTrigger.exe')"
Write-Host " SIOMIN: $(Join-Path $Dist 'PlandentSiominClientTrigger.exe')"
Write-Host '' Write-Host ''
Write-Host 'Die Client-EXE enthält die native MSTSC-Plugin-DLL als eingebettete Resource.' Write-Host 'Alle Komponenten sind native x64-C++-Binaries; .NET/NuGet wird nicht benötigt.'
Write-Host 'Die Server-EXE sendet ausschließlich den festen START-Trigger über den RDP-DVC.' Write-Host 'Die Client-EXE enthält die native MSTSC-Plugin-DLL als RCDATA-Resource.'
Write-Host 'Die generische Server-EXE sendet ausschließlich den festen START-Trigger über den RDP-DVC.'
Write-Host 'Die SIOMIN-EXE fragt den Client-Hostname über den DVC ab, passt die SDX bytegenau an und sendet danach START.'
@@ -0,0 +1,4 @@
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=C:\PDATA\siomin.sdx
DEBUG=0
+14
View File
@@ -22,3 +22,17 @@ Der neue Terminalserver-Trigger ist in diesem Preset absichtlich deaktiviert. Da
``` ```
Die EXE sendet nur das feste `START`-Kommando. Das tatsächlich zu startende Programm wird ausschließlich auf dem Client konfiguriert. Die EXE sendet nur das feste `START`-Kommando. Das tatsächlich zu startende Programm wird ausschließlich auf dem Client konfiguriert.
## SIOMIN-Trigger
`PlandentSiominClientTrigger.exe` verwendet eine gleichnamige INI. Standard:
```ini
[SIOMIN]
SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx
SIOMIN_PATH=C:\PDATA\siomin.sdx
DEBUG=0
```
Mit `DEBUG=1` wird neben der EXE eine gleichnamige `.log`-Datei mit allen Verarbeitungsschritten erzeugt.
Nach erfolgreicher Uebernahme in die finale `siomin.sdx` wird die Quelle `temp_siomin.sdx` auf 0 Bytes geleert.