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:
@@ -3,5 +3,6 @@
|
|||||||
**/obj/
|
**/obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
dist/
|
dist/
|
||||||
|
Configurator/Generated/
|
||||||
*.user
|
*.user
|
||||||
*.suo
|
*.suo
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#include "resource.h"
|
||||||
|
|
||||||
|
IDR_PLUGIN_DLL RCDATA "Generated\\PlandentMstscScriptHook.dll"
|
||||||
@@ -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. & exit /b 1)
|
||||||
|
copy /Y "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" "$(ProjectDir)Generated\PlandentMstscScriptHook.dll" >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. & exit /b 1)
|
||||||
|
copy /Y "$(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\PlandentMstscScriptHook.dll" "$(ProjectDir)Generated\PlandentMstscScriptHook.dll" >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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define IDR_PLUGIN_DLL 101
|
||||||
+643
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.");
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user