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/
|
||||
artifacts/
|
||||
dist/
|
||||
Configurator/Generated/
|
||||
*.user
|
||||
*.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
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Plugin", "Plugin\PlandentMstscScriptHook.vcxproj", "{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlandentMstscScriptHook.Configurator", "Configurator\Configurator.csproj", "{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}"
|
||||
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentMstscScriptHook.Configurator", "Configurator\Configurator.vcxproj", "{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} = {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}
|
||||
EndProjectSection
|
||||
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}"
|
||||
EndProject
|
||||
Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "PlandentSiominClientTrigger", "SiominTrigger\PlandentSiominClientTrigger.vcxproj", "{B96418A5-A9A0-4FD5-A9EB-3DE8897A754A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Release|x64 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Debug|x64.Build.0 = Debug|x64
|
||||
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Release|x64.ActiveCfg = Release|x64
|
||||
{D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA}.Release|x64.Build.0 = Release|x64
|
||||
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Debug|x64.Build.0 = Debug|x64
|
||||
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.ActiveCfg = Release|x64
|
||||
{935FA3CC-4D72-4AE5-A23B-A74AE54A873A}.Release|x64.Build.0 = Release|x64
|
||||
{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.Build.0 = Debug|x64
|
||||
{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.ActiveCfg = Release|x64
|
||||
{A71B1703-2FD5-4777-A50C-6D6C21D1F4AE}.Release|x64.Build.0 = Release|x64
|
||||
{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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<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>
|
||||
@@ -71,6 +72,7 @@
|
||||
<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>
|
||||
|
||||
+171
-16
@@ -10,10 +10,80 @@
|
||||
|
||||
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
|
||||
{
|
||||
public:
|
||||
TriggerChannelCallback() = default;
|
||||
explicit TriggerChannelCallback(IWTSVirtualChannel* channel)
|
||||
: channel_(channel)
|
||||
{
|
||||
if (channel_ != nullptr)
|
||||
{
|
||||
channel_->AddRef();
|
||||
}
|
||||
}
|
||||
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override
|
||||
{
|
||||
@@ -57,32 +127,111 @@ namespace
|
||||
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 ||
|
||||
std::memcmp(buffer, hook::protocol::kStartMessage, hook::protocol::kStartMessageLength) != 0)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
hook::Log(config.enableLogging, L"DVC-Trigger: START vom Terminalserver empfangen.");
|
||||
|
||||
bool startSucceeded = false;
|
||||
|
||||
if (!config.enabled)
|
||||
{
|
||||
hook::Log(config.enableLogging, L"DVC-Trigger: Plugin-Funktion ist deaktiviert; Start ignoriert.");
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
if (!config.triggerEnabled)
|
||||
else if (!config.triggerEnabled)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -94,8 +243,17 @@ namespace
|
||||
}
|
||||
|
||||
private:
|
||||
~TriggerChannelCallback() = default;
|
||||
~TriggerChannelCallback()
|
||||
{
|
||||
if (channel_ != nullptr)
|
||||
{
|
||||
channel_->Release();
|
||||
channel_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
ReleaseDvcObjects();
|
||||
|
||||
// Der DVC-Trigger ist eine optionale Zusatzfunktion. Ein nicht verfügbarer
|
||||
// DVC darf den bestehenden Connected()/Disconnected()-Hook nicht deaktivieren.
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
hook::Log(config.enableLogging,
|
||||
L"DVC-Listener für 'plandent::mstsc-script-hook' wurde registriert.");
|
||||
L"DVC-Listener fuer 'plandent::mstsc-script-hook' wurde registriert.");
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
@@ -197,7 +353,6 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::Connected()
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// Nur Registry lesen + CreateProcess aufrufen. Es wird niemals auf das Programm gewartet.
|
||||
connectLauncher_.Start(config.connectProgram, config.enableLogging, L"RDP-Verbindungsprogramm");
|
||||
return S_OK;
|
||||
}
|
||||
@@ -256,7 +411,7 @@ HRESULT STDMETHODCALLTYPE ScriptHookPlugin::OnNewChannelConnection(
|
||||
return E_POINTER;
|
||||
}
|
||||
|
||||
auto* channelCallback = new (std::nothrow) TriggerChannelCallback();
|
||||
auto* channelCallback = new (std::nothrow) TriggerChannelCallback(channel);
|
||||
if (channelCallback == nullptr)
|
||||
{
|
||||
hook::Log(config.enableLogging, L"DVC-Trigger: Callback konnte nicht angelegt werden.");
|
||||
|
||||
@@ -1,283 +1,234 @@
|
||||
# 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**.
|
||||
2. **Gezielt durch `PlandentRdpClientTrigger.exe` innerhalb der Terminalserver-Sitzung**.
|
||||
Das Projekt ist insbesondere für drei praktische Einsatzfälle vorgesehen:
|
||||
|
||||
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
|
||||
|
||||
### Client
|
||||
### Lokaler RDP-Client
|
||||
|
||||
- **PlandentMstscScriptHook.dll** – native x64 DVC-Plugin-DLL, die von `mstsc.exe` geladen wird.
|
||||
- **PlandentMstscScriptHook.exe** – WinForms-Konfigurator/Installer. Die Release-EXE enthält die DLL als Resource und extrahiert sie bei der Installation.
|
||||
**`PlandentMstscScriptHook.exe`**
|
||||
|
||||
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
|
||||
|
||||
- **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
|
||||
LOKALER CLIENT TERMINALSERVER
|
||||
================ ==============
|
||||
Programm/Script:
|
||||
C:\RemoteVDDS\RemoteVDDS_Receiver_TS.bat
|
||||
|
||||
mstsc.exe
|
||||
│
|
||||
└── PlandentMstscScriptHook.dll
|
||||
│
|
||||
├── Connected()
|
||||
│ └── optional: lokales RDP-Verbindungsprogramm
|
||||
│
|
||||
└── DVC Listener
|
||||
plandent::mstsc-script-hook
|
||||
▲
|
||||
│ fester Befehl:
|
||||
│ PLANDENT_MSTSC_HOOK/1 START
|
||||
│
|
||||
└──────────────────── PlandentRdpClientTrigger.exe
|
||||
│
|
||||
└── läuft in der RDP-Sitzung
|
||||
Argumente:
|
||||
MIN
|
||||
|
||||
Nach START liest die DLL ausschließlich lokal:
|
||||
HKCU\Software\Plandent\MstscScriptHook
|
||||
TriggerProgramPath
|
||||
TriggerArguments
|
||||
TriggerWorkingDirectory
|
||||
Unsichtbar starten:
|
||||
Ja
|
||||
|
||||
Mehrfachstart verhindern:
|
||||
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,
|
||||
- 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.
|
||||
## Programmstart aus der RDP-Sitzung
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
Innerhalb der RDP-Sitzung wird anschließend lediglich ausgeführt:
|
||||
|
||||
```text
|
||||
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
|
||||
C:\Tools\PlandentRdpClientTrigger.exe
|
||||
Für Programme, die innerhalb der Terminalserver-Sitzung laufen, aber ein **lokal installiertes Sidexis** ansprechen sollen, steht `PlandentSiominClientTrigger.exe` zur Verfügung.
|
||||
|
||||
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
|
||||
& 'C:\Tools\PlandentRdpClientTrigger.exe'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Client-Trigger fehlgeschlagen. ExitCode: $LASTEXITCODE"
|
||||
}
|
||||
.\build-release.ps1 -Clean
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Der Server-Trigger muss innerhalb einer aktiven RDP-Sitzung ausgeführt werden, wenn `WTS_CURRENT_SESSION` verwendet wird.
|
||||
- Das lokale Programm läuft mit den Rechten des Benutzers, der `mstsc.exe` auf dem Client gestartet hat.
|
||||
- Der Server kann über dieses Protokoll **keine** beliebigen Programme oder Parameter vorgeben.
|
||||
- `StopOnDisconnect` gilt ausschließlich für das automatische RDP-Verbindungsprogramm, nicht für das per Server ausgelöste Programm.
|
||||
- AppLocker/WDAC oder andere Application-Control-Richtlinien können das Laden einer nicht signierten DLL bzw. den Programmstart blockieren.
|
||||
- `Home.md` – Anwender-/Technikerdokumentation
|
||||
- `DEVELOPMENT.md` – technische Entwicklerdokumentation
|
||||
|
||||
## Hinweise
|
||||
|
||||
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).
|
||||
|
||||
+57
-11
@@ -1,33 +1,45 @@
|
||||
#include "../Shared/TriggerProtocol.h"
|
||||
#include "../Shared/DvcServerIo.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <wtsapi32.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <stdlib.h>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr DWORD kAckTimeoutMs = 5000;
|
||||
|
||||
enum class ExitCode : int
|
||||
{
|
||||
Success = 0,
|
||||
ChannelOpenFailed = 10,
|
||||
WriteFailed = 11,
|
||||
PartialWrite = 12
|
||||
PartialWrite = 12,
|
||||
AckReadFailed = 13,
|
||||
StartRejected = 14,
|
||||
InvalidAck = 15
|
||||
};
|
||||
|
||||
void DebugError(const wchar_t* step, DWORD error)
|
||||
{
|
||||
wchar_t buffer[256]{};
|
||||
swprintf_s(buffer, _countof(buffer), L"PlandentRdpClientTrigger: %s fehlgeschlagen, Win32=%lu\n", step, error);
|
||||
swprintf_s(
|
||||
buffer,
|
||||
(sizeof(buffer) / sizeof(buffer[0])),
|
||||
L"PlandentRdpClientTrigger: %s fehlgeschlagen, Win32=%lu\n",
|
||||
step,
|
||||
error);
|
||||
OutputDebugStringW(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
// WTS_CURRENT_SESSION stellt sicher, dass ausschließlich der Client der
|
||||
// RDP-Sitzung angesprochen wird, in der diese EXE ausgeführt wird.
|
||||
// WTS_CURRENT_SESSION stellt sicher, dass ausschliesslich der Client der
|
||||
// RDP-Sitzung angesprochen wird, in der diese EXE ausgefuehrt wird.
|
||||
HANDLE channel = WTSVirtualChannelOpenEx(
|
||||
WTS_CURRENT_SESSION,
|
||||
const_cast<LPSTR>(hook::protocol::kChannelName),
|
||||
@@ -40,26 +52,60 @@ int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
}
|
||||
|
||||
ULONG bytesWritten = 0;
|
||||
BOOL written = WTSVirtualChannelWrite(
|
||||
const BOOL written = WTSVirtualChannelWrite(
|
||||
channel,
|
||||
const_cast<PCHAR>(hook::protocol::kStartMessage),
|
||||
static_cast<ULONG>(hook::protocol::kStartMessageLength),
|
||||
&bytesWritten);
|
||||
|
||||
DWORD writeError = written ? ERROR_SUCCESS : GetLastError();
|
||||
WTSVirtualChannelClose(channel);
|
||||
|
||||
if (!written)
|
||||
{
|
||||
DebugError(L"WTSVirtualChannelWrite", writeError);
|
||||
const DWORD error = GetLastError();
|
||||
WTSVirtualChannelClose(channel);
|
||||
DebugError(L"WTSVirtualChannelWrite", error);
|
||||
return static_cast<int>(ExitCode::WriteFailed);
|
||||
}
|
||||
|
||||
if (bytesWritten != static_cast<ULONG>(hook::protocol::kStartMessageLength))
|
||||
{
|
||||
WTSVirtualChannelClose(channel);
|
||||
OutputDebugStringW(L"PlandentRdpClientTrigger: Trigger-Nachricht wurde nur teilweise geschrieben.\n");
|
||||
return static_cast<int>(ExitCode::PartialWrite);
|
||||
}
|
||||
|
||||
return static_cast<int>(ExitCode::Success);
|
||||
// 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);
|
||||
}
|
||||
|
||||
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>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
|
||||
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
@@ -70,6 +71,7 @@
|
||||
<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>
|
||||
@@ -85,6 +87,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\Shared\TriggerProtocol.h" />
|
||||
<ClInclude Include="..\Shared\DvcServerIo.h" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets" />
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="..\Shared\TriggerProtocol.h"><Filter>Header Files</Filter></ClInclude>
|
||||
<ClInclude Include="..\Shared\DvcServerIo.h"><Filter>Header Files</Filter></ClInclude>
|
||||
</ItemGroup>
|
||||
</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
|
||||
{
|
||||
// Der Dynamic-Virtual-Channel-Name muss auf Client und Terminalserver identisch sein.
|
||||
// DVC-Namen dürfen laut WTSVirtualChannelOpenEx bis MAX_PATH lang sein.
|
||||
inline constexpr char kChannelName[] = "plandent::mstsc-script-hook";
|
||||
|
||||
// Absichtlich ein festes Protokollkommando ohne Pfad/Argumente.
|
||||
// Der Terminalserver kann damit nur den lokal vorkonfigurierten Start anfordern.
|
||||
inline constexpr char kStartMessage[] = "PLANDENT_MSTSC_HOOK/1 START";
|
||||
inline constexpr std::size_t kStartMessageLength = sizeof(kStartMessage) - 1;
|
||||
|
||||
// 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'
|
||||
$Root = Split-Path -Parent $PSCommandPath
|
||||
$PluginProject = Join-Path $Root 'Plugin\PlandentMstscScriptHook.vcxproj'
|
||||
$ServerTriggerProject = Join-Path $Root 'ServerTrigger\PlandentRdpClientTrigger.vcxproj'
|
||||
$ConfiguratorProject = Join-Path $Root 'Configurator\Configurator.csproj'
|
||||
$Solution = Join-Path $Root 'PlandentMstscScriptHook.sln'
|
||||
$Dist = Join-Path $Root 'dist'
|
||||
$Artifacts = Join-Path $Root 'artifacts'
|
||||
$GeneratedPayload = Join-Path $Root 'Configurator\Generated'
|
||||
|
||||
function Find-MSBuild {
|
||||
$cmd = Get-Command msbuild.exe -ErrorAction SilentlyContinue
|
||||
@@ -31,58 +30,41 @@ function Find-MSBuild {
|
||||
if ($Clean) {
|
||||
Remove-Item $Artifacts -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
|
||||
|
||||
$MSBuild = Find-MSBuild
|
||||
|
||||
Write-Host "[1/3] Native MSTSC Plugin-DLL bauen ($Configuration|x64)..." -ForegroundColor Cyan
|
||||
& $MSBuild $PluginProject /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64'
|
||||
Write-Host "[1/2] Native x64-Projektmappe neu bauen ($Configuration|x64)..." -ForegroundColor Cyan
|
||||
& $MSBuild $Solution /t:Rebuild /m /nologo "/p:Configuration=$Configuration" '/p:Platform=x64'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Native DLL Build fehlgeschlagen (ExitCode $LASTEXITCODE)."
|
||||
throw "Build fehlgeschlagen (ExitCode $LASTEXITCODE)."
|
||||
}
|
||||
|
||||
$PluginDll = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.dll"
|
||||
if (-not (Test-Path $PluginDll)) {
|
||||
throw "Erwartete DLL wurde nicht erzeugt: $PluginDll"
|
||||
}
|
||||
|
||||
Write-Host "[2/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)."
|
||||
}
|
||||
|
||||
$ConfiguratorExe = Join-Path $Artifacts "x64\$Configuration\PlandentMstscScriptHook.exe"
|
||||
$ServerTriggerExe = Join-Path $Artifacts "x64\$Configuration\PlandentRdpClientTrigger.exe"
|
||||
if (-not (Test-Path $ServerTriggerExe)) {
|
||||
throw "Erwartete Server-Trigger-EXE wurde nicht erzeugt: $ServerTriggerExe"
|
||||
$SiominTriggerExe = Join-Path $Artifacts "x64\$Configuration\PlandentSiominClientTrigger.exe"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
}
|
||||
Copy-Item $SiominTriggerExe (Join-Path $Dist 'PlandentSiominClientTrigger.exe') -Force
|
||||
|
||||
Write-Host ''
|
||||
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 " SIOMIN: $(Join-Path $Dist 'PlandentSiominClientTrigger.exe')"
|
||||
Write-Host ''
|
||||
Write-Host 'Die Client-EXE enthält die native MSTSC-Plugin-DLL als eingebettete Resource.'
|
||||
Write-Host 'Die Server-EXE sendet ausschließlich den festen START-Trigger über den RDP-DVC.'
|
||||
Write-Host 'Alle Komponenten sind native x64-C++-Binaries; .NET/NuGet wird nicht benötigt.'
|
||||
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.
|
||||
|
||||
## 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