diff --git a/.gitignore b/.gitignore index 2fc0729..d45514f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ **/obj/ artifacts/ dist/ +Configurator/Generated/ *.user *.suo diff --git a/Configurator/Config.h b/Configurator/Config.h new file mode 100644 index 0000000..f9930b8 --- /dev/null +++ b/Configurator/Config.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +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); +} diff --git a/Configurator/Configurator.csproj b/Configurator/Configurator.csproj deleted file mode 100644 index 97557a4..0000000 --- a/Configurator/Configurator.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - WinExe - net8.0-windows - true - enable - enable - PlandentMstscScriptHook - Plandent.MstscScriptHook.Configurator - x64 - x64 - $(MSBuildProjectDirectory)\..\artifacts\x64\$(Configuration)\PlandentMstscScriptHook.dll - - - - - false - false - portable - - - - - win-x64 - true - true - false - embedded - - - - - - - - - - diff --git a/Configurator/Configurator.rc b/Configurator/Configurator.rc new file mode 100644 index 0000000..6833261 --- /dev/null +++ b/Configurator/Configurator.rc @@ -0,0 +1,3 @@ +#include "resource.h" + +IDR_PLUGIN_DLL RCDATA "Generated\\PlandentMstscScriptHook.dll" diff --git a/Configurator/Configurator.vcxproj b/Configurator/Configurator.vcxproj new file mode 100644 index 0000000..d63b90d --- /dev/null +++ b/Configurator/Configurator.vcxproj @@ -0,0 +1,134 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + 17.0 + Win32Proj + {935FA3CC-4D72-4AE5-A23B-A74AE54A873A} + PlandentMstscScriptHookConfigurator + 10.0 + + + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + + + $(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\ + $(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\Configurator\ + PlandentMstscScriptHook + + + + + Level4 + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + /utf-8 %(AdditionalOptions) + MultiThreadedDebug + + + UNICODE;_UNICODE;%(PreprocessorDefinitions) + + + Windows + true + Advapi32.lib;Shell32.lib;Comdlg32.lib;Ole32.lib;Comctl32.lib;User32.lib;Gdi32.lib;%(AdditionalDependencies) + + + 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 + + + + + + Level4 + true + true + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + /utf-8 %(AdditionalOptions) + MultiThreaded + + + UNICODE;_UNICODE;%(PreprocessorDefinitions) + + + Windows + true + true + false + Advapi32.lib;Shell32.lib;Comdlg32.lib;Ole32.lib;Comctl32.lib;User32.lib;Gdi32.lib;%(AdditionalDependencies) + + + 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 + + + + + + + + + + + + + + + + + + + + + {D42B7E9A-92FC-4B92-8C3F-E32D1291D2CA} + false + false + + + + + + diff --git a/Configurator/Configurator.vcxproj.filters b/Configurator/Configurator.vcxproj.filters new file mode 100644 index 0000000..fb64387 --- /dev/null +++ b/Configurator/Configurator.vcxproj.filters @@ -0,0 +1,32 @@ + + + + + {D7D3B9B1-4B80-4A4B-888D-E7CA10A76FC5} + cpp;c;cxx;cc + + + {6C56EC9E-3B39-44B2-B913-74A798B7EA8F} + h;hpp;hxx + + + {37447730-171A-4380-A30C-CA33A0A2FF38} + rc;ico;cur;bmp + + + + Source Files + Source Files + Source Files + Source Files + + + Header Files + Header Files + Header Files + Header Files + + + Resource Files + + diff --git a/Configurator/HookConfig.cs b/Configurator/HookConfig.cs deleted file mode 100644 index 1713e93..0000000 --- a/Configurator/HookConfig.cs +++ /dev/null @@ -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; -} diff --git a/Configurator/Main.cpp b/Configurator/Main.cpp new file mode 100644 index 0000000..d508214 --- /dev/null +++ b/Configurator/Main.cpp @@ -0,0 +1,925 @@ +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#include +#include +#include + +#include "Config.h" +#include "PluginInstaller.h" +#include "TestLauncher.h" + +#include +#include +#include +#include +#include + +#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(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(static_cast(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 buffer(static_cast(len) + 1, L'\0'); + GetWindowTextW(control, buffer.data(), static_cast(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(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(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(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(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(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(message.wParam); +} diff --git a/Configurator/MainForm.cs b/Configurator/MainForm.cs deleted file mode 100644 index 3980cb5..0000000 --- a/Configurator/MainForm.cs +++ /dev/null @@ -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}"); - } - } - } -} diff --git a/Configurator/PluginInstaller.cpp b/Configurator/PluginInstaller.cpp new file mode 100644 index 0000000..9ad6705 --- /dev/null +++ b/Configurator/PluginInstaller.cpp @@ -0,0 +1,227 @@ +#include "PluginInstaller.h" +#include "Config.h" +#include "resource.h" + +#include +#include +#include + +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 buffer(32768, L'\0'); + DWORD len = GetModuleFileNameW(nullptr, buffer.data(), static_cast(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; + } +} diff --git a/Configurator/PluginInstaller.cs b/Configurator/PluginInstaller.cs deleted file mode 100644 index f2d040d..0000000 --- a/Configurator/PluginInstaller.cs +++ /dev/null @@ -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); diff --git a/Configurator/PluginInstaller.h b/Configurator/PluginInstaller.h new file mode 100644 index 0000000..8ff1c89 --- /dev/null +++ b/Configurator/PluginInstaller.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +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(); +} diff --git a/Configurator/Program.cs b/Configurator/Program.cs deleted file mode 100644 index 0c24be8..0000000 --- a/Configurator/Program.cs +++ /dev/null @@ -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()); - } -} diff --git a/Configurator/RegistryConfig.cpp b/Configurator/RegistryConfig.cpp new file mode 100644 index 0000000..e628daf --- /dev/null +++ b/Configurator/RegistryConfig.cpp @@ -0,0 +1,212 @@ +#include "Config.h" + +#include +#include + +namespace +{ + DWORD ReadDword(HKEY key, const wchar_t* name, DWORD defaultValue) + { + DWORD value = defaultValue; + DWORD size = sizeof(value); + DWORD type = 0; + if (RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(&value), &size) != ERROR_SUCCESS || + type != REG_DWORD) + { + return defaultValue; + } + return value; + } + + std::wstring 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 buffer((size / sizeof(wchar_t)) + 1, L'\0'); + if (RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(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(&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((value.size() + 1) * sizeof(wchar_t)); + LONG result = RegSetValueExW( + key, + name, + 0, + type, + reinterpret_cast(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 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; + } +} diff --git a/Configurator/RegistryConfig.cs b/Configurator/RegistryConfig.cs deleted file mode 100644 index 393f13e..0000000 --- a/Configurator/RegistryConfig.cs +++ /dev/null @@ -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; - } -} diff --git a/Configurator/ScriptTestLauncher.cs b/Configurator/ScriptTestLauncher.cs deleted file mode 100644 index 759c232..0000000 --- a/Configurator/ScriptTestLauncher.cs +++ /dev/null @@ -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 Split(string commandLine) - { - var result = new List(); - var current = new System.Text.StringBuilder(); - bool inQuotes = false; - int backslashes = 0; - - for (int i = 0; i < commandLine.Length; i++) - { - char ch = commandLine[i]; - - if (ch == '\\') - { - backslashes++; - continue; - } - - if (ch == '"') - { - current.Append('\\', backslashes / 2); - if (backslashes % 2 == 0) - { - inQuotes = !inQuotes; - } - else - { - current.Append('"'); - } - backslashes = 0; - continue; - } - - current.Append('\\', backslashes); - backslashes = 0; - - if (char.IsWhiteSpace(ch) && !inQuotes) - { - if (current.Length > 0) - { - result.Add(current.ToString()); - current.Clear(); - } - } - else - { - current.Append(ch); - } - } - - current.Append('\\', backslashes); - if (current.Length > 0) - { - result.Add(current.ToString()); - } - - return result; - } -} diff --git a/Configurator/TestLauncher.cpp b/Configurator/TestLauncher.cpp new file mode 100644 index 0000000..e7a961e --- /dev/null +++ b/Configurator/TestLauncher.cpp @@ -0,0 +1,299 @@ +#include "TestLauncher.h" +#include "Config.h" + +#include +#include +#include + +namespace +{ + std::wstring QuoteWindowsArgument(const std::wstring& arg) + { + if (arg.empty()) + { + return L"\"\""; + } + if (arg.find_first_of(L" \t\n\v\"") == std::wstring::npos) + { + return arg; + } + + std::wstring result = L"\""; + size_t backslashes = 0; + for (wchar_t ch : arg) + { + if (ch == L'\\') + { + ++backslashes; + continue; + } + if (ch == L'\"') + { + result.append(backslashes * 2 + 1, L'\\'); + result.push_back(L'\"'); + backslashes = 0; + continue; + } + result.append(backslashes, L'\\'); + backslashes = 0; + result.push_back(ch); + } + result.append(backslashes * 2, L'\\'); + result.push_back(L'\"'); + return result; + } + + std::wstring 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(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 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(&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 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; + } +} diff --git a/Configurator/TestLauncher.h b/Configurator/TestLauncher.h new file mode 100644 index 0000000..eb63bee --- /dev/null +++ b/Configurator/TestLauncher.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +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(); + }; +} diff --git a/Configurator/resource.h b/Configurator/resource.h new file mode 100644 index 0000000..9191fc1 --- /dev/null +++ b/Configurator/resource.h @@ -0,0 +1,3 @@ +#pragma once + +#define IDR_PLUGIN_DLL 101 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..7388737 --- /dev/null +++ b/DEVELOPMENT.md @@ -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 +``` + +### 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 +\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. diff --git a/Home.md b/Home.md new file mode 100644 index 0000000..9c90be2 --- /dev/null +++ b/Home.md @@ -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 | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e4a0aaf --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PlandentMstscScriptHook.sln b/PlandentMstscScriptHook.sln index 4b6d3f6..a9661b5 100644 --- a/PlandentMstscScriptHook.sln +++ b/PlandentMstscScriptHook.sln @@ -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 diff --git a/Plugin/PlandentMstscScriptHook.vcxproj b/Plugin/PlandentMstscScriptHook.vcxproj index ec915af..66ce09f 100644 --- a/Plugin/PlandentMstscScriptHook.vcxproj +++ b/Plugin/PlandentMstscScriptHook.vcxproj @@ -53,6 +53,7 @@ WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) true stdcpp17 + /utf-8 %(AdditionalOptions) MultiThreadedDebug @@ -71,6 +72,7 @@ WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) true stdcpp17 + /utf-8 %(AdditionalOptions) MultiThreaded diff --git a/Plugin/ScriptHookPlugin.cpp b/Plugin/ScriptHookPlugin.cpp index 1cff37d..48d582d 100644 --- a/Plugin/ScriptHookPlugin.cpp +++ b/Plugin/ScriptHookPlugin.cpp @@ -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(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(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(value.size()), + nullptr, + 0, + nullptr, + nullptr); + + if (required <= 0) + { + return {}; + } + + std::string result(static_cast(required), '\0'); + const int written = WideCharToMultiByte( + CP_UTF8, + WC_ERR_INVALID_CHARS, + value.data(), + static_cast(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(response.size()), + reinterpret_cast(response.data()), + nullptr); + + if (FAILED(hr)) + { + hook::Log(config.enableLogging, + L"DVC-Hostname: Antwort konnte nicht geschrieben werden. HRESULT=" + + std::to_wstring(static_cast(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(responseLength), + reinterpret_cast(const_cast(response)), + nullptr); + + if (FAILED(hr)) + { + hook::Log(config.enableLogging, + L"DVC-Trigger: START-Quittung konnte nicht geschrieben werden. HRESULT=" + + std::to_wstring(static_cast(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."); diff --git a/README.md b/README.md index af2b31d..bb8b69b 100644 --- a/README.md +++ b/README.md @@ -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\\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. -``` - -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). diff --git a/ServerTrigger/Main.cpp b/ServerTrigger/Main.cpp index 90eb9d8..bc4dd6c 100644 --- a/ServerTrigger/Main.cpp +++ b/ServerTrigger/Main.cpp @@ -1,33 +1,45 @@ #include "../Shared/TriggerProtocol.h" +#include "../Shared/DvcServerIo.h" #include #include +#include #include -#include +#include 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(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(hook::protocol::kStartMessage), static_cast(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(ExitCode::WriteFailed); } if (bytesWritten != static_cast(hook::protocol::kStartMessageLength)) { + WTSVirtualChannelClose(channel); OutputDebugStringW(L"PlandentRdpClientTrigger: Trigger-Nachricht wurde nur teilweise geschrieben.\n"); return static_cast(ExitCode::PartialWrite); } - return static_cast(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 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(ExitCode::AckReadFailed); + } + + if (response.size() == hook::protocol::kStartOkResponseLength && + std::memcmp( + response.data(), + hook::protocol::kStartOkResponse, + hook::protocol::kStartOkResponseLength) == 0) + { + return static_cast(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(ExitCode::StartRejected); + } + + OutputDebugStringW(L"PlandentRdpClientTrigger: Ungueltige START-Quittung empfangen.\n"); + return static_cast(ExitCode::InvalidAck); } diff --git a/ServerTrigger/PlandentRdpClientTrigger.vcxproj b/ServerTrigger/PlandentRdpClientTrigger.vcxproj index b79b2f3..427a044 100644 --- a/ServerTrigger/PlandentRdpClientTrigger.vcxproj +++ b/ServerTrigger/PlandentRdpClientTrigger.vcxproj @@ -53,6 +53,7 @@ WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) true stdcpp17 + /utf-8 %(AdditionalOptions) MultiThreadedDebug @@ -70,6 +71,7 @@ WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) true stdcpp17 + /utf-8 %(AdditionalOptions) MultiThreaded @@ -85,6 +87,7 @@ + diff --git a/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters b/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters index 5d0cf2d..d35be6a 100644 --- a/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters +++ b/ServerTrigger/PlandentRdpClientTrigger.vcxproj.filters @@ -9,5 +9,6 @@ Header Files + Header Files diff --git a/Shared/DvcServerIo.h b/Shared/DvcServerIo.h new file mode 100644 index 0000000..d823b3f --- /dev/null +++ b/Shared/DvcServerIo.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +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& 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(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(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(timeoutMs, 1000); + } + } +} diff --git a/Shared/TriggerProtocol.h b/Shared/TriggerProtocol.h index cb2acd8..ac70e04 100644 --- a/Shared/TriggerProtocol.h +++ b/Shared/TriggerProtocol.h @@ -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; } diff --git a/SiominTrigger/Main.cpp b/SiominTrigger/Main.cpp new file mode 100644 index 0000000..c557c0c --- /dev/null +++ b/SiominTrigger/Main.cpp @@ -0,0 +1,1216 @@ +#include "../Shared/TriggerProtocol.h" +#include "../Shared/DvcServerIo.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr wchar_t kIniSection[] = L"SIOMIN"; + constexpr wchar_t kIniTempPathKey[] = L"SIOMIN_TEMP_PATH"; + constexpr wchar_t kIniPathKey[] = L"SIOMIN_PATH"; + constexpr wchar_t kIniDebugKey[] = L"DEBUG"; + constexpr wchar_t kDefaultTempPath[] = L"C:\\PDATA\\temp_siomin.sdx"; + constexpr wchar_t kDefaultSiominPath[] = L"C:\\PDATA\\siomin.sdx"; + constexpr DWORD kDvcTimeoutMs = 5000; + + enum class ExitCode : int + { + Success = 0, + IniPathFailed = 20, + IniCreateFailed = 21, + ChannelOpenFailed = 22, + HostnameRequestWriteFailed = 23, + HostnameResponseReadFailed = 24, + InvalidHostnameResponse = 25, + InvalidClientHostname = 26, + TempFileReadFailed = 27, + NoSiominHostFieldFound = 28, + FinalPathInvalid = 29, + FinalFileOpenFailed = 30, + FinalFileBoundaryInvalid = 31, + FinalFileWriteFailed = 32, + StartTriggerWriteFailed = 33, + TempFileClearFailed = 34, + StartAckReadFailed = 35, + StartRejected = 36, + InvalidStartAck = 37, + RollbackFailed = 38 + }; + + std::filesystem::path GetOwnPath() + { + std::vector buffer(32768, L'\0'); + const DWORD chars = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (chars == 0 || chars >= buffer.size()) + { + return {}; + } + + return std::filesystem::path(std::wstring(buffer.data(), chars)); + } + + std::string WideToUtf8(const std::wstring& value) + { + if (value.empty()) + { + return {}; + } + + const int required = WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + nullptr, + 0, + nullptr, + nullptr); + + if (required <= 0) + { + return {}; + } + + std::string result(static_cast(required), '\0'); + const int written = WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + required, + nullptr, + nullptr); + + if (written != required) + { + return {}; + } + + return result; + } + + class DebugLogger + { + public: + void Initialize(bool enabled, const std::filesystem::path& exePath) + { + enabled_ = enabled; + logPath_ = exePath; + logPath_.replace_extension(L".log"); + } + + void Log(const std::wstring& message) const + { + const std::wstring debugLine = L"PlandentSiominClientTrigger: " + message + L"\n"; + OutputDebugStringW(debugLine.c_str()); + + if (!enabled_ || logPath_.empty()) + { + return; + } + + SYSTEMTIME st{}; + GetLocalTime(&st); + + wchar_t prefix[96]{}; + swprintf_s( + prefix, + sizeof(prefix) / sizeof(prefix[0]), + L"%04u-%02u-%02u %02u:%02u:%02u.%03u [PID:%lu] ", + st.wYear, + st.wMonth, + st.wDay, + st.wHour, + st.wMinute, + st.wSecond, + st.wMilliseconds, + GetCurrentProcessId()); + + const std::string utf8 = WideToUtf8(std::wstring(prefix) + message + L"\r\n"); + if (utf8.empty()) + { + return; + } + + HANDLE file = CreateFileW( + logPath_.c_str(), + FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + return; + } + + DWORD written = 0; + WriteFile( + file, + utf8.data(), + static_cast(utf8.size()), + &written, + nullptr); + CloseHandle(file); + } + + void LogWin32Error(const std::wstring& step, DWORD error) const + { + Log(step + L" fehlgeschlagen, Win32=" + std::to_wstring(error)); + } + + const std::filesystem::path& Path() const + { + return logPath_; + } + + private: + bool enabled_ = false; + std::filesystem::path logPath_; + }; + + void DebugMessage(const std::wstring& message) + { + const std::wstring line = L"PlandentSiominClientTrigger: " + message + L"\n"; + OutputDebugStringW(line.c_str()); + } + + bool WriteNewUnicodeIni(const std::filesystem::path& iniPath) + { + const std::wstring content = + std::wstring(L"[") + kIniSection + L"]\r\n" + + kIniTempPathKey + L"=" + kDefaultTempPath + L"\r\n" + + kIniPathKey + L"=" + kDefaultSiominPath + L"\r\n" + + kIniDebugKey + L"=0\r\n"; + + HANDLE file = CreateFileW( + iniPath.c_str(), + GENERIC_WRITE, + FILE_SHARE_READ, + nullptr, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + return GetLastError() == ERROR_FILE_EXISTS; + } + + const BYTE bom[] = { 0xFF, 0xFE }; + DWORD written = 0; + BOOL ok = WriteFile(file, bom, static_cast(sizeof(bom)), &written, nullptr); + if (!ok || written != sizeof(bom)) + { + CloseHandle(file); + return false; + } + + const DWORD bytes = static_cast(content.size() * sizeof(wchar_t)); + written = 0; + ok = WriteFile(file, content.data(), bytes, &written, nullptr); + const bool result = ok && written == bytes; + if (result) + { + FlushFileBuffers(file); + } + CloseHandle(file); + return result; + } + + std::wstring ReadIniValue( + const std::filesystem::path& iniPath, + const wchar_t* key, + const wchar_t* defaultValue) + { + std::vector buffer(32768, L'\0'); + const DWORD chars = GetPrivateProfileStringW( + kIniSection, + key, + defaultValue, + buffer.data(), + static_cast(buffer.size()), + iniPath.c_str()); + + if (chars == 0) + { + return defaultValue; + } + + return std::wstring(buffer.data(), chars); + } + + bool ReadIniBool(const std::filesystem::path& iniPath, const wchar_t* key, bool defaultValue) + { + return GetPrivateProfileIntW( + kIniSection, + key, + defaultValue ? 1 : 0, + iniPath.c_str()) != 0; + } + + bool EnsureDebugIniKey(const std::filesystem::path& iniPath) + { + constexpr wchar_t kMissingMarker[] = L"__PLANDENT_MISSING__"; + wchar_t value[64]{}; + GetPrivateProfileStringW( + kIniSection, + kIniDebugKey, + kMissingMarker, + value, + static_cast(sizeof(value) / sizeof(value[0])), + iniPath.c_str()); + + if (wcscmp(value, kMissingMarker) != 0) + { + return true; + } + + return WritePrivateProfileStringW( + kIniSection, + kIniDebugKey, + L"0", + iniPath.c_str()) != FALSE; + } + + bool WriteChannelMessage(HANDLE channel, const char* message, std::size_t length, DWORD& win32Error) + { + win32Error = ERROR_SUCCESS; + ULONG bytesWritten = 0; + const BOOL ok = WTSVirtualChannelWrite( + channel, + const_cast(message), + static_cast(length), + &bytesWritten); + + if (!ok) + { + win32Error = GetLastError(); + return false; + } + + if (bytesWritten != static_cast(length)) + { + win32Error = ERROR_WRITE_FAULT; + return false; + } + + return true; + } + + bool Utf8ToWide(const std::string& value, std::wstring& result) + { + result.clear(); + if (value.empty()) + { + return false; + } + + const int required = MultiByteToWideChar( + CP_UTF8, + MB_ERR_INVALID_CHARS, + value.data(), + static_cast(value.size()), + nullptr, + 0); + + if (required <= 0) + { + return false; + } + + result.resize(static_cast(required)); + const int written = MultiByteToWideChar( + CP_UTF8, + MB_ERR_INVALID_CHARS, + value.data(), + static_cast(value.size()), + result.data(), + required); + + return written == required; + } + + enum class HostnameQueryResult + { + Success, + WriteFailed, + ReadFailed, + InvalidResponse + }; + + HostnameQueryResult QueryClientHostname( + HANDLE channel, + std::wstring& hostname, + DWORD& win32Error, + const DebugLogger& log) + { + hostname.clear(); + win32Error = ERROR_SUCCESS; + + log.Log(L"Sende GET_CLIENT_HOSTNAME ueber DVC."); + if (!WriteChannelMessage( + channel, + hook::protocol::kGetClientHostnameMessage, + hook::protocol::kGetClientHostnameMessageLength, + win32Error)) + { + return HostnameQueryResult::WriteFailed; + } + + std::vector response; + if (!hook::dvc::ReadMessage(channel, kDvcTimeoutMs, response, win32Error)) + { + return HostnameQueryResult::ReadFailed; + } + + log.Log(L"DVC-Hostname-Antwort empfangen, Payload-Bytes=" + std::to_wstring(response.size()) + L"."); + + if (response.size() <= hook::protocol::kClientHostnameResponsePrefixLength || + std::memcmp( + response.data(), + hook::protocol::kClientHostnameResponsePrefix, + hook::protocol::kClientHostnameResponsePrefixLength) != 0) + { + win32Error = ERROR_INVALID_DATA; + return HostnameQueryResult::InvalidResponse; + } + + const std::string hostnameUtf8( + response.data() + hook::protocol::kClientHostnameResponsePrefixLength, + response.data() + response.size()); + + if (!Utf8ToWide(hostnameUtf8, hostname)) + { + win32Error = ERROR_NO_UNICODE_TRANSLATION; + return HostnameQueryResult::InvalidResponse; + } + + return HostnameQueryResult::Success; + } + + bool HostnameToSiominBytes(const std::wstring& hostname, std::vector& result) + { + result.clear(); + if (hostname.empty() || hostname.size() > 255) + { + return false; + } + + // Das beobachtete SIOMIN-Format verwendet einbyteige, NUL-separierte Felder. + // Fuer Rechnernamen akzeptieren wir daher bewusst nur druckbares ASCII. + result.reserve(hostname.size()); + for (wchar_t ch : hostname) + { + if (ch < 0x20 || ch > 0x7E || ch == L'\\' || ch == L'/') + { + return false; + } + result.push_back(static_cast(ch)); + } + + return !result.empty(); + } + + bool ReadAllBytes(const std::filesystem::path& path, std::vector& bytes) + { + bytes.clear(); + std::ifstream stream(path, std::ios::binary | std::ios::ate); + if (!stream) + { + return false; + } + + const std::streamoff length = stream.tellg(); + if (length < 0) + { + return false; + } + + bytes.resize(static_cast(length)); + stream.seekg(0, std::ios::beg); + if (!bytes.empty()) + { + stream.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!stream) + { + return false; + } + } + + return true; + } + + std::size_t FindByte( + const std::vector& data, + std::size_t begin, + std::size_t end, + std::uint8_t value) + { + for (std::size_t i = begin; i < end; ++i) + { + if (data[i] == value) + { + return i; + } + } + return end; + } + + bool IsSiominActionRecordAt( + const std::vector& data, + std::size_t recordStart, + std::size_t& recordEnd) + { + recordEnd = recordStart; + + // WICHTIG: + // Das erste Byte eines SIOMIN-Datensatzes ist KEIN Kennzeichen, + // sondern die Gesamtlaenge des Datensatzes in Bytes (inkl. Laengenbyte + // und abschliessendem CRLF). + // + // Beispiele aus realen Dateien: + // 0x3A 00 'N' 00 ... -> Laenge 58 + // 0x40 00 'N' 00 ... -> Laenge 64 + // 0x46 00 'A' 00 ... -> Laenge 70 + // 0x4C 00 'A' 00 ... -> Laenge 76 + // + // Der Datensatztyp steht erst im dritten Byte ('N' bzw. 'A'). + if (recordStart + 4 > data.size()) + { + return false; + } + + const std::size_t recordLength = data[recordStart]; + if (recordLength < 6 || recordStart + recordLength > data.size()) + { + return false; + } + + recordEnd = recordStart + recordLength; + + if (data[recordStart + 1] != 0x00 || + data[recordStart + 2] != static_cast('A') || + data[recordStart + 3] != 0x00) + { + return false; + } + + // Die beobachteten SIOMIN-Datensaetze enden mit CRLF. Damit verhindern + // wir, dass bei einer korrupten Laengenangabe in Folgedaten geparst wird. + if (recordLength < 2 || + data[recordEnd - 2] != 0x0D || + data[recordEnd - 1] != 0x0A) + { + return false; + } + + return true; + } + + std::wstring HexPreview(const std::vector& data, std::size_t maxBytes = 256) + { + if (data.empty()) + { + return L""; + } + + const std::size_t count = std::min(data.size(), maxBytes); + std::wstring result; + result.reserve(count * 3 + 32); + + constexpr wchar_t hex[] = L"0123456789ABCDEF"; + for (std::size_t i = 0; i < count; ++i) + { + if (i != 0) + { + result.push_back(L' '); + } + + const std::uint8_t value = data[i]; + result.push_back(hex[(value >> 4) & 0x0F]); + result.push_back(hex[value & 0x0F]); + } + + if (count < data.size()) + { + result += L" ..."; + } + + return result; + } + + struct HostFieldRange + { + std::size_t recordStart = 0; + std::size_t recordEnd = 0; // exklusiv + std::size_t begin = 0; + std::size_t end = 0; // exklusiv; das abschliessende NUL bleibt erhalten + std::uint8_t originalRecordLength = 0; + }; + + bool TryGetHostnameField( + const std::vector& source, + std::size_t recordStart, + HostFieldRange& range) + { + range = {}; + + std::size_t recordEnd = 0; + if (!IsSiominActionRecordAt(source, recordStart, recordEnd)) + { + return false; + } + + // SIOMIN-Aktionsdatensatz: + // LENGTH\0 A\0 Nachname\0 Vorname\0 Geburtsdatum\0 ID\0 HOSTNAME\0 Datum\0 Uhrzeit... + // + // Vom Beginn des Datensatzes aus muessen sechs NUL-Trenner uebersprungen + // werden. Danach beginnt das Hostname-Feld. + std::size_t fieldStart = recordStart; + for (int field = 0; field < 6; ++field) + { + const std::size_t separator = FindByte(source, fieldStart, recordEnd, 0x00); + if (separator == recordEnd) + { + return false; + } + + for (std::size_t i = fieldStart; i < separator; ++i) + { + if (source[i] == 0x0D || source[i] == 0x0A) + { + return false; + } + } + + fieldStart = separator + 1; + } + + if (fieldStart >= recordEnd) + { + return false; + } + + const std::size_t fieldEnd = FindByte(source, fieldStart, recordEnd, 0x00); + if (fieldEnd == recordEnd || fieldEnd == fieldStart) + { + return false; + } + + for (std::size_t i = fieldStart; i < fieldEnd; ++i) + { + if (source[i] == 0x0D || source[i] == 0x0A) + { + return false; + } + } + + range.recordStart = recordStart; + range.recordEnd = recordEnd; + range.begin = fieldStart; + range.end = fieldEnd; + range.originalRecordLength = source[recordStart]; + return true; + } + + bool ReplaceSiominHostnameFields( + const std::vector& source, + const std::vector& hostname, + std::vector& transformed, + std::size_t& replacementCount) + { + transformed.clear(); + replacementCount = 0; + + if (source.empty() || hostname.empty()) + { + return false; + } + + // SIOMIN ist laengencodiert: Das erste Byte jedes Datensatzes enthaelt + // seine Gesamtlaenge. Deshalb duerfen wir beim Ersetzen eines Hostnamens + // nicht nur den Text austauschen, sondern MUESSEN auch dieses Laengenbyte + // um die Groessendifferenz korrigieren. + // + // Beispiel: + // alter Datensatz: 0x4C = 76 Bytes, Host "TS-FR" = 5 Bytes + // neuer Host: "FR-Empfang-3R" = 13 Bytes + // neue Laenge: 76 + 8 = 84 = 0x54 + std::vector replacements; + + std::size_t recordStart = 0; + while (recordStart < source.size()) + { + if (recordStart + 1 > source.size()) + { + transformed = source; + return false; + } + + const std::size_t recordLength = source[recordStart]; + if (recordLength == 0 || recordStart + recordLength > source.size()) + { + transformed = source; + return false; + } + + const std::size_t recordEnd = recordStart + recordLength; + + // Grundlegende Integritaetspruefung fuer alle beobachteten Datensaetze. + if (recordLength < 2 || + source[recordEnd - 2] != 0x0D || + source[recordEnd - 1] != 0x0A) + { + transformed = source; + return false; + } + + HostFieldRange range{}; + if (TryGetHostnameField(source, recordStart, range)) + { + const std::size_t oldHostnameLength = range.end - range.begin; + const std::ptrdiff_t delta = + static_cast(hostname.size()) - + static_cast(oldHostnameLength); + + const std::ptrdiff_t newRecordLength = + static_cast(range.originalRecordLength) + delta; + + // Das Format hat nur ein Byte fuer die Datensatzlaenge. + if (newRecordLength <= 0 || newRecordLength > 255) + { + transformed = source; + return false; + } + + replacements.push_back(range); + } + + recordStart = recordEnd; + } + + if (recordStart != source.size() || replacements.empty()) + { + transformed = source; + return false; + } + + std::size_t outputSize = source.size(); + for (const HostFieldRange& range : replacements) + { + outputSize -= (range.end - range.begin); + outputSize += hostname.size(); + } + + transformed.reserve(outputSize); + std::size_t copyFrom = 0; + + for (const HostFieldRange& range : replacements) + { + // Alle Bytes vor diesem Datensatz unveraendert uebernehmen. + transformed.insert( + transformed.end(), + source.begin() + static_cast(copyFrom), + source.begin() + static_cast(range.recordStart)); + + const std::size_t outputRecordStart = transformed.size(); + + // Datensatz bis direkt vor den Hostnamen kopieren. + transformed.insert( + transformed.end(), + source.begin() + static_cast(range.recordStart), + source.begin() + static_cast(range.begin)); + + const std::size_t oldHostnameLength = range.end - range.begin; + const std::ptrdiff_t delta = + static_cast(hostname.size()) - + static_cast(oldHostnameLength); + const std::size_t newRecordLength = + static_cast( + static_cast(range.originalRecordLength) + delta); + + // Das bereits kopierte erste Byte ist die alte Datensatzlaenge. + // Es muss auf die neue tatsaechliche Laenge gesetzt werden. + transformed[outputRecordStart] = static_cast(newRecordLength); + + transformed.insert(transformed.end(), hostname.begin(), hostname.end()); + + // NUL hinter dem alten Hostnamen und den gesamten Rest des Datensatzes + // unveraendert uebernehmen. + transformed.insert( + transformed.end(), + source.begin() + static_cast(range.end), + source.begin() + static_cast(range.recordEnd)); + + copyFrom = range.recordEnd; + ++replacementCount; + } + + transformed.insert( + transformed.end(), + source.begin() + static_cast(copyFrom), + source.end()); + + return replacementCount > 0; + } + + bool IsSamePath(const std::filesystem::path& a, const std::filesystem::path& b) + { + const std::wstring aa = std::filesystem::absolute(a).lexically_normal().wstring(); + const std::wstring bb = std::filesystem::absolute(b).lexically_normal().wstring(); + return _wcsicmp(aa.c_str(), bb.c_str()) == 0; + } + + struct AppendState + { + bool existed = false; + std::uint64_t originalSize = 0; + }; + + bool AppendBytesPreservingExistingFile( + const std::filesystem::path& finalPath, + const std::vector& bytes, + bool& invalidBoundary, + AppendState& appendState) + { + invalidBoundary = false; + appendState = {}; + + std::error_code ec; + appendState.existed = std::filesystem::exists(finalPath, ec); + if (ec) + { + return false; + } + + if (appendState.existed) + { + const std::uintmax_t size = std::filesystem::file_size(finalPath, ec); + if (ec) + { + return false; + } + appendState.originalSize = static_cast(size); + + if (size > 0) + { + if (size < 2) + { + invalidBoundary = true; + return false; + } + + HANDLE existing = CreateFileW( + finalPath.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (existing == INVALID_HANDLE_VALUE) + { + return false; + } + + LARGE_INTEGER offset{}; + offset.QuadPart = -2; + if (!SetFilePointerEx(existing, offset, nullptr, FILE_END)) + { + CloseHandle(existing); + return false; + } + + BYTE tail[2]{}; + DWORD read = 0; + const BOOL readOk = ReadFile(existing, tail, 2, &read, nullptr); + CloseHandle(existing); + if (!readOk || read != 2) + { + return false; + } + + if (!(tail[0] == 0x0D && tail[1] == 0x0A)) + { + invalidBoundary = true; + return false; + } + } + } + else + { + const std::filesystem::path parent = finalPath.parent_path(); + if (!parent.empty()) + { + std::filesystem::create_directories(parent, ec); + if (ec) + { + return false; + } + } + } + + HANDLE file = CreateFileW( + finalPath.c_str(), + FILE_APPEND_DATA, + FILE_SHARE_READ, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + return false; + } + + std::size_t offset = 0; + bool ok = true; + while (offset < bytes.size()) + { + const DWORD chunk = static_cast( + std::min(bytes.size() - offset, 1024 * 1024)); + DWORD written = 0; + if (!WriteFile(file, bytes.data() + offset, chunk, &written, nullptr) || written != chunk) + { + ok = false; + break; + } + offset += written; + } + + if (ok) + { + ok = FlushFileBuffers(file) != FALSE; + } + CloseHandle(file); + return ok; + } + + bool TruncateFileToSize(const std::filesystem::path& path, std::uint64_t size) + { + HANDLE file = CreateFileW( + path.c_str(), + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr); + + if (file == INVALID_HANDLE_VALUE) + { + return false; + } + + LARGE_INTEGER position{}; + position.QuadPart = static_cast(size); + bool ok = SetFilePointerEx(file, position, nullptr, FILE_BEGIN) != FALSE; + if (ok) + { + ok = SetEndOfFile(file) != FALSE; + } + if (ok) + { + ok = FlushFileBuffers(file) != FALSE; + } + + CloseHandle(file); + return ok; + } + + bool ClearSourceFile(const std::filesystem::path& path) + { + return TruncateFileToSize(path, 0); + } + + bool RollbackAppend(const std::filesystem::path& finalPath, const AppendState& state) + { + if (!state.existed) + { + return DeleteFileW(finalPath.c_str()) != FALSE; + } + + return TruncateFileToSize(finalPath, state.originalSize); + } + + enum class StartTriggerResult + { + Success, + OpenFailed, + WriteFailed, + AckReadFailed, + Rejected, + InvalidAck + }; + + StartTriggerResult SendStartTrigger(DWORD& win32Error, const DebugLogger& log) + { + win32Error = ERROR_SUCCESS; + + log.Log(L"Oeffne neuen DVC fuer START-Trigger."); + HANDLE channel = WTSVirtualChannelOpenEx( + WTS_CURRENT_SESSION, + const_cast(hook::protocol::kChannelName), + WTS_CHANNEL_OPTION_DYNAMIC | WTS_CHANNEL_OPTION_DYNAMIC_PRI_MED); + + if (channel == nullptr) + { + win32Error = GetLastError(); + return StartTriggerResult::OpenFailed; + } + + log.Log(L"Sende START ueber DVC."); + if (!WriteChannelMessage( + channel, + hook::protocol::kStartMessage, + hook::protocol::kStartMessageLength, + win32Error)) + { + WTSVirtualChannelClose(channel); + return StartTriggerResult::WriteFailed; + } + + std::vector response; + if (!hook::dvc::ReadMessage(channel, kDvcTimeoutMs, response, win32Error)) + { + WTSVirtualChannelClose(channel); + return StartTriggerResult::AckReadFailed; + } + + WTSVirtualChannelClose(channel); + log.Log(L"START-Quittung empfangen, Payload-Bytes=" + std::to_wstring(response.size()) + L"."); + + if (response.size() == hook::protocol::kStartOkResponseLength && + std::memcmp( + response.data(), + hook::protocol::kStartOkResponse, + hook::protocol::kStartOkResponseLength) == 0) + { + return StartTriggerResult::Success; + } + + if (response.size() == hook::protocol::kStartFailedResponseLength && + std::memcmp( + response.data(), + hook::protocol::kStartFailedResponse, + hook::protocol::kStartFailedResponseLength) == 0) + { + return StartTriggerResult::Rejected; + } + + win32Error = ERROR_INVALID_DATA; + return StartTriggerResult::InvalidAck; + } +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) +{ + const std::filesystem::path exePath = GetOwnPath(); + if (exePath.empty()) + { + DebugMessage(L"Eigener EXE-Pfad konnte nicht ermittelt werden."); + return static_cast(ExitCode::IniPathFailed); + } + + std::filesystem::path iniPath = exePath; + iniPath.replace_extension(L".ini"); + + std::error_code ec; + if (!std::filesystem::exists(iniPath, ec)) + { + if (ec || !WriteNewUnicodeIni(iniPath)) + { + DebugMessage(L"INI konnte nicht angelegt werden: " + iniPath.wstring()); + return static_cast(ExitCode::IniCreateFailed); + } + } + + // Auch bei bereits vorhandenen INIs aus aelteren Versionen wird DEBUG=0 + // einmalig ergaenzt, ohne die bestehenden Pfade zu veraendern. + if (!EnsureDebugIniKey(iniPath)) + { + DebugMessage(L"DEBUG-Schluessel konnte in der INI nicht ergaenzt werden; Verarbeitung laeuft ohne Datei-Log weiter."); + } + + DebugLogger log; + log.Initialize(ReadIniBool(iniPath, kIniDebugKey, false), exePath); + log.Log(L"=== Start ==="); + log.Log(L"EXE=" + exePath.wstring()); + log.Log(L"INI=" + iniPath.wstring()); + if (ReadIniBool(iniPath, kIniDebugKey, false)) + { + log.Log(L"DEBUG=1, Logdatei=" + log.Path().wstring()); + } + + const std::filesystem::path tempPath = ReadIniValue(iniPath, kIniTempPathKey, kDefaultTempPath); + const std::filesystem::path finalPath = ReadIniValue(iniPath, kIniPathKey, kDefaultSiominPath); + + log.Log(L"SIOMIN_TEMP_PATH=" + tempPath.wstring()); + log.Log(L"SIOMIN_PATH=" + finalPath.wstring()); + + if (tempPath.empty() || finalPath.empty() || IsSamePath(tempPath, finalPath)) + { + log.Log(L"SIOMIN_TEMP_PATH/SIOMIN_PATH sind ungueltig oder identisch."); + return static_cast(ExitCode::FinalPathInvalid); + } + + log.Log(L"Oeffne DVC fuer Client-Hostname-Abfrage."); + HANDLE hostnameChannel = WTSVirtualChannelOpenEx( + WTS_CURRENT_SESSION, + const_cast(hook::protocol::kChannelName), + WTS_CHANNEL_OPTION_DYNAMIC | WTS_CHANNEL_OPTION_DYNAMIC_PRI_MED); + + if (hostnameChannel == nullptr) + { + log.LogWin32Error(L"WTSVirtualChannelOpenEx (Hostname)", GetLastError()); + return static_cast(ExitCode::ChannelOpenFailed); + } + + std::wstring clientHostname; + DWORD dvcError = ERROR_SUCCESS; + const HostnameQueryResult hostnameResult = QueryClientHostname( + hostnameChannel, + clientHostname, + dvcError, + log); + WTSVirtualChannelClose(hostnameChannel); + + if (hostnameResult != HostnameQueryResult::Success) + { + if (hostnameResult == HostnameQueryResult::WriteFailed) + { + log.LogWin32Error(L"Hostname-Anfrage ueber DVC schreiben", dvcError); + return static_cast(ExitCode::HostnameRequestWriteFailed); + } + if (hostnameResult == HostnameQueryResult::ReadFailed) + { + log.LogWin32Error(L"Client-Hostname ueber DVC lesen", dvcError); + return static_cast(ExitCode::HostnameResponseReadFailed); + } + + log.LogWin32Error(L"Ungueltige DVC-Hostname-Antwort", dvcError); + return static_cast(ExitCode::InvalidHostnameResponse); + } + + log.Log(L"Client-Hostname=" + clientHostname); + + std::vector hostnameBytes; + if (!HostnameToSiominBytes(clientHostname, hostnameBytes)) + { + log.Log(L"Client-Hostname ist fuer das SIOMIN-Byteformat nicht geeignet: " + clientHostname); + return static_cast(ExitCode::InvalidClientHostname); + } + + std::vector tempBytes; + if (!ReadAllBytes(tempPath, tempBytes)) + { + log.Log(L"SIOMIN_TEMP_PATH konnte nicht gelesen werden: " + tempPath.wstring()); + return static_cast(ExitCode::TempFileReadFailed); + } + + log.Log(L"Temp-SDX gelesen, Bytes=" + std::to_wstring(tempBytes.size()) + L"."); + + std::vector transformed; + std::size_t replacementCount = 0; + if (!ReplaceSiominHostnameFields(tempBytes, hostnameBytes, transformed, replacementCount)) + { + log.Log(L"Kein gueltiges SIOMIN-Aktions-/Hostname-Feld in der temp_siomin.sdx gefunden."); + log.Log(L"Temp-SDX Hex-Vorschau: " + HexPreview(tempBytes)); + return static_cast(ExitCode::NoSiominHostFieldFound); + } + + log.Log( + L"SIOMIN-Transformation erfolgreich: Hostfelder=" + + std::to_wstring(replacementCount) + + L", Ausgabe-Bytes=" + std::to_wstring(transformed.size()) + L"."); + + bool invalidBoundary = false; + AppendState appendState{}; + if (!AppendBytesPreservingExistingFile(finalPath, transformed, invalidBoundary, appendState)) + { + if (invalidBoundary) + { + log.Log(L"Bestehende siomin.sdx endet nicht mit CRLF; aus Sicherheitsgruenden nicht veraendert."); + return static_cast(ExitCode::FinalFileBoundaryInvalid); + } + + log.LogWin32Error(L"SIOMIN_PATH anlegen/erweitern", GetLastError()); + return static_cast(ExitCode::FinalFileWriteFailed); + } + + log.Log( + L"Finale SIOMIN-Datei erweitert. Vorherige Groesse=" + + std::to_wstring(appendState.originalSize) + + L", angehaengte Bytes=" + std::to_wstring(transformed.size()) + L"."); + + // Erst nachdem die finale Datei sicher geschrieben und geflusht wurde, wird + // die Quell-Datei geleert. Dadurch wird derselbe SIOMIN-Eintrag beim naechsten + // Aufruf nicht erneut verarbeitet. + if (!ClearSourceFile(tempPath)) + { + const DWORD clearError = GetLastError(); + log.LogWin32Error(L"temp_siomin.sdx leeren", clearError); + + // Verhindert Dubletten: Wenn die Quelle nicht geleert werden konnte, + // versuchen wir den soeben angehaengten Block wieder zurueckzurollen. + if (!RollbackAppend(finalPath, appendState)) + { + log.LogWin32Error(L"Rollback der finalen siomin.sdx", GetLastError()); + return static_cast(ExitCode::RollbackFailed); + } + + log.Log(L"Finale siomin.sdx wurde wegen Fehler beim Leeren der Quelle erfolgreich zurueckgerollt."); + return static_cast(ExitCode::TempFileClearFailed); + } + + log.Log(L"temp_siomin.sdx nach erfolgreicher Uebernahme auf 0 Bytes geleert."); + + DWORD startError = ERROR_SUCCESS; + const StartTriggerResult startResult = SendStartTrigger(startError, log); + if (startResult != StartTriggerResult::Success) + { + if (startResult == StartTriggerResult::OpenFailed) + { + log.LogWin32Error(L"DVC fuer START oeffnen", startError); + return static_cast(ExitCode::ChannelOpenFailed); + } + if (startResult == StartTriggerResult::WriteFailed) + { + log.LogWin32Error(L"START-Trigger schreiben", startError); + return static_cast(ExitCode::StartTriggerWriteFailed); + } + if (startResult == StartTriggerResult::AckReadFailed) + { + log.LogWin32Error(L"START-Quittung lesen", startError); + return static_cast(ExitCode::StartAckReadFailed); + } + if (startResult == StartTriggerResult::Rejected) + { + log.Log(L"Client hat START mit START_FAILED beantwortet."); + return static_cast(ExitCode::StartRejected); + } + + log.LogWin32Error(L"Ungueltige START-Quittung", startError); + return static_cast(ExitCode::InvalidStartAck); + } + + log.Log(L"START_OK vom Client empfangen."); + log.Log(L"=== Erfolgreich beendet ==="); + return static_cast(ExitCode::Success); +} \ No newline at end of file diff --git a/SiominTrigger/PlandentSiominClientTrigger.vcxproj b/SiominTrigger/PlandentSiominClientTrigger.vcxproj new file mode 100644 index 0000000..cd485ec --- /dev/null +++ b/SiominTrigger/PlandentSiominClientTrigger.vcxproj @@ -0,0 +1,94 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {B96418A5-A9A0-4FD5-A9EB-3DE8897A754A} + PlandentSiominClientTrigger + 10.0 + + + + Application + true + v143 + Unicode + + + Application + false + v143 + true + Unicode + + + + + + + + + + + + + $(ProjectDir)..\artifacts\$(Platform)\$(Configuration)\ + $(ProjectDir)..\artifacts\obj\$(Platform)\$(Configuration)\SiominTrigger\ + PlandentSiominClientTrigger + + + + Level4 + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;_DEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + /utf-8 %(AdditionalOptions) + MultiThreadedDebug + + + Windows + true + Wtsapi32.lib;%(AdditionalDependencies) + + + + + Level4 + true + true + true + WIN32_LEAN_AND_MEAN;NOMINMAX;UNICODE;_UNICODE;NDEBUG;%(PreprocessorDefinitions) + true + stdcpp17 + /utf-8 %(AdditionalOptions) + MultiThreaded + + + Windows + true + true + false + Wtsapi32.lib;%(AdditionalDependencies) + + + + + + + + + + + + diff --git a/SiominTrigger/PlandentSiominClientTrigger.vcxproj.filters b/SiominTrigger/PlandentSiominClientTrigger.vcxproj.filters new file mode 100644 index 0000000..4acc32c --- /dev/null +++ b/SiominTrigger/PlandentSiominClientTrigger.vcxproj.filters @@ -0,0 +1,26 @@ + + + + + {63E7B94A-4947-4D12-AE99-D6C33739AE76} + cpp;c;cc;cxx + + + {87F56E92-224D-40FD-805D-FE0BC91D9C56} + h;hpp;hxx + + + + + Source Files + + + + + Header Files + + + Header Files + + + diff --git a/build-release.ps1 b/build-release.ps1 index 0b5dc48..fff1741 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -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.' diff --git a/examples/PlandentSiominClientTrigger.ini.example b/examples/PlandentSiominClientTrigger.ini.example new file mode 100644 index 0000000..10d1897 --- /dev/null +++ b/examples/PlandentSiominClientTrigger.ini.example @@ -0,0 +1,4 @@ +[SIOMIN] +SIOMIN_TEMP_PATH=C:\PDATA\temp_siomin.sdx +SIOMIN_PATH=C:\PDATA\siomin.sdx +DEBUG=0 diff --git a/examples/README.md b/examples/README.md index be556fd..682e729 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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.