RDP DVC advanced plugin – .NET 8

A complete .NET 8 sample demonstrating Remote Desktop Protocol Dynamic Virtual Channels (DVC), including client plugins, a server application, COM activation models, and client-side bitmap rendering.

This sample implements:

  • RDP Dynamic Virtual Channel client plugins
  • A server application running inside an RDP session
  • Three different COM activation models
  • Protocol Buffers messaging for cross-language serialization
  • RDP render hints and client-side bitmap rendering

The system measures round-trip latency by sending ping messages across the virtual channel and echoing them back. The bitmap renderer generates a scrolling rainbow pattern to demonstrate client-side injection of bitmaps into the RDP client graphics pipeline.

Quick Facts

  • Channel: dvc::sample::advancedplugin
  • CLSID: {D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3} (shared by all client plugins)
  • Client outputs:
    • LocalServer EXE: bin/<Platform>/<Configuration>/rdp-plugin-client-localserver-csharp.exe
    • InprocServer DLL + COM host: bin/<Platform>/<Configuration>/rdp-plugin-client-inprocserver-csharp.dll and rdp-plugin-client-inprocserver-csharp.comhost.dll
    • LoadLibrary Native AOT DLL: bin/<Platform>/<Configuration>/native/rdp-plugin-client-loadlibrary-csharp.dll
  • Server output: bin/<Platform>/<Configuration>/rdp-plugin-server-csharp.exe
  • Register:
    • LocalServer: /register (or /register /machine for machine-wide)
    • Inproc: regsvr32 rdp-plugin-client-inprocserver-csharp.comhost.dll
    • LoadLibrary: regsvr32 rdp-plugin-client-loadlibrary-csharp.dll (exports VirtualChannelGetInstance)
  • Server modes: WTS (sync) or file-handle (async)

Table of Contents

Quick Start

Install Prerequisites (.NET SDK, Visual Studio with C++ for Native AOT)

# From this directory (Advanced\dotnet)
.\build.ps1 -Install

Build All Projects

# From this directory (Advanced\dotnet)
.\build.ps1

# Or build specific configuration
.\build.ps1 -Configuration Release -Platform x64

# Or build specific project
.\build.ps1 -Project server -Configuration Debug

Test the Implementation

  1. Choose and register a client plugin (see Plugin Registration)
  2. Run the server inside an RDP session:
    bin\x64\Debug\rdp-plugin-server-csharp.exe
    
  3. Connect via RDP client to the session
  4. Observe:
    • Server logs RTT measurements
    • Client plugin echoes data back
    • Bitmap locally rendered and injected into the RDP client window

Contents

File/folder Description
rdp-plugin-client-common/ Shared plugin infrastructure (class library).
rdp-plugin-protocol/ Protocol Buffers message definitions (class library).
rdp-plugin-client-inprocserver/ In-process COM server plugin (DLL via comhost).
rdp-plugin-client-loadlibrary/ LoadLibrary/VirtualChannelGetInstance plugin (Native AOT DLL).
rdp-plugin-client-localserver/ Out-of-process COM server plugin (EXE).
rdp-plugin-server/ Server-side DVC host application (EXE).
RdpDvcAdvanced.sln Visual Studio solution file.
build.ps1 PowerShell build script.
README.md This README file.

This directory contains .NET 8 implementations showcasing three different COM activation models for RDP client plugins, plus a server-side DVC host application and shared infrastructure.

Key Features

  • Modern .NET 8 with Native AOT support
  • Three COM activation models (InprocServer32 via comhost, LoadLibrary via Native AOT, LocalServer32)
  • Protocol Buffers via Google.Protobuf NuGet package
  • Render hints + bitmap renderer demonstrating the full RDP graphics pipeline for media offloading
  • Two server I/O modes: synchronous WTS API and asynchronous file-handle (OVERLAPPED)

Shared Infrastructure

  • rdp-plugin-client-common - Shared library (class library)

    • Common plugin implementation (IWTSPlugin, IWTSListenerCallback, IWTSVirtualChannelCallback)
    • RdpPluginBase abstract base class with full DVC lifecycle management
    • BitmapRendererManager for client-side rendering
    • COM class factory utilities (BasicClassFactory)
    • Referenced by all client plugins
  • rdp-plugin-protocol - Protocol library (class library)

    • Protocol Buffers message definitions and helpers
    • Message framing (length-prefix + protobuf payload)
    • PingMessageHelper for message construction and parsing
    • Shared by both client and server

Client Plugins (3 Activation Models)

  • rdp-plugin-client-inprocserver - In-process COM server (DLL)

    • Uses <EnableComHosting>true</EnableComHosting> for .NET COM hosting
    • Produces *.comhost.dll native shim for COM registration
    • Loaded by the RDP client via CoCreateInstance
  • rdp-plugin-client-loadlibrary - Direct LoadLibrary (Native AOT DLL)

    • Uses <PublishAot>true</PublishAot> and <NativeLib>Shared</NativeLib>
    • Exports VirtualChannelGetInstance function via [UnmanagedCallersOnly]
    • Alternative to COM activation (no CLSID registration required)
  • rdp-plugin-client-localserver - Out-of-process COM server (EXE)

    • Runs as separate process via CoRegisterClassObject
    • Provides process isolation from the RDP client
    • Easier debugging and independent lifecycle

Server Application

  • rdp-plugin-server - Server-side DVC host (EXE)
    • Runs inside RDP session on remote machine
    • Opens DVC channel to communicate with client plugins
    • Demonstrates session lifecycle, reconnect handling, render hints
    • Two I/O modes: WTS API (sync) and file handle (async OVERLAPPED)

Prerequisites

  • .NET 8 SDK or later
  • Windows SDK 10.0.17763.0 or later
  • Visual Studio 2022 with "Desktop development with C++" workload (required for Native AOT loadlibrary build)

Run .\build.ps1 -Install to automatically install .NET 8 SDK via winget.

Setup

NuGet Dependencies

All dependencies are restored automatically via NuGet:

  • CsWin32 - Windows API source generator (compile-time P/Invoke generation)
  • Google.Protobuf - Protocol Buffers runtime
  • Grpc.Tools - .proto compilation (build tool)
  • Microsoft.Extensions.Logging.Abstractions - Logging abstractions
  • System.Drawing.Common - Bitmap/image support for client-side rendering

Build Commands

Local Build (this directory):

# Auto-detect platform, build all projects in Debug
.\build.ps1

# Install prerequisites (.NET SDK, Visual Studio with C++ for Native AOT)
.\build.ps1 -Install

# Specific configuration and platform
.\build.ps1 -Configuration Release -Platform ARM64

# Clean build outputs
.\build.ps1 -Clean

# Force rebuild (clean first)
.\build.ps1 -Force

# Build specific project
.\build.ps1 -Project common           # Build rdp-plugin-client-common only
.\build.ps1 -Project protocol         # Build rdp-plugin-protocol only
.\build.ps1 -Project server           # Build server application
.\build.ps1 -Project inprocserver     # Build InprocServer plugin
.\build.ps1 -Project loadlibrary      # Build LoadLibrary plugin (Native AOT)
.\build.ps1 -Project localserver      # Build LocalServer plugin

Build Outputs

Binaries are placed in:

  • bin\{Platform}\{Configuration}\ - Executables and DLLs
  • bin\{Platform}\{Configuration}\native\ - Native AOT compiled DLL (loadlibrary)
  • obj\{ProjectName}\{Platform}\{Configuration}\ - Intermediate files

Native AOT

The rdp-plugin-client-loadlibrary project uses Native AOT compilation to produce a native DLL that exports VirtualChannelGetInstance.

Project Configuration:

<PublishAot>true</PublishAot>
<NativeLib>Shared</NativeLib>

Build Command:

# The build script automatically publishes loadlibrary for Native AOT
.\build.ps1 -Project loadlibrary -Platform x64

# Or build all projects (loadlibrary is always published)
.\build.ps1 -Platform x64

The build script automatically uses dotnet publish for the loadlibrary project (instead of dotnet build) to trigger Native AOT compilation.

Native AOT Prerequisites:

  • Visual Studio with "Desktop development with C++" workload installed
  • Windows SDK (10.0.17763.0 or later)
  • For ARM64 builds: ARM64 build tools and libraries

Build Environment Setup:

The build script (build.ps1) automatically configures the correct Visual Studio and Windows SDK library paths for the target architecture. This is essential because the Native AOT linker needs to find the correct platform-specific libraries (x64, ARM64, or x86).

If building manually without the script, you may need to set the LIB environment variable:

# Example for x64 builds
$env:LIB = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.xx.xxxxx\lib\x64;C:\Program Files (x86)\Windows Kits\10\Lib\10.0.xxxxx.0\ucrt\x64;C:\Program Files (x86)\Windows Kits\10\Lib\10.0.xxxxx.0\um\x64"
dotnet publish rdp-plugin-client-loadlibrary -r win-x64 -c Debug

Troubleshooting

If you see NuGet restore errors:

  1. Delete obj\ folder
  2. Run .\build.ps1 -Clean
  3. Rebuild the solution — NuGet will re-restore all packages

Architecture

Client Plugin Flow

RDP Client
    ├─> Loads plugin DLL or creates a COM server
    ├─> Calls IWTSPlugin::Initialize
    ├─> Listens for DVC connections on "dvc::sample::advancedplugin"
    ├─> IWTSListenerCallback::OnNewChannelConnection
    ├─> IWTSVirtualChannelCallback receives data
    ├─> Echoes data back to server
    └─> IWTSPlugin::Terminated on shutdown

Server Application Flow

RDP Session (remote machine)
    ├─> Registers for session notifications (WTSRegisterSessionNotificationEx)
    ├─> Opens DVC channel (WTSVirtualChannelOpenEx)
    ├─> Sends ping messages with render hints
    ├─> Receives echo responses, measures RTT
    ├─> Handles session disconnect/reconnect
    └─> Graceful shutdown on exit events

Protocol

All implementations use Protocol Buffers for message serialization:

Message Structure:

[4-byte length prefix (little-endian)]
[Protocol Buffer payload (PingMessage)]

PingMessage fields:

  • sequence_number - Incrementing message counter
  • utc_ticks - Timestamp (100ns ticks since Unix epoch)
  • payload - Data bytes (echo test)
  • render_hint_id - Window/region identifier for RDP optimization
  • render_hint_type - Hint type (e.g., RENDER_HINT_MAPPEDWINDOW)

RDP Graphics APIs

Render Hints (Server-Side)

Purpose: Associate application windows with RDP protocol regions to optimize encoding and transmission. The server creates render hints that inform the RDP host about content regions requiring special handling.

API: wtshintapi.h - WTSSetRenderHint()

Implementation in rdp-plugin-server:

// Create a render hint for a window region
ulong hintId = 0;
RECT hintRect = new() { left = 0, top = 0, right = width, bottom = height };

unsafe {
    int hr = PInvoke.WTSSetRenderHint(
        &hintId,                    // [in/out] Hint ID (0 = create new)
        hWnd,                       // Associated window handle
        RENDER_HINT_MAPPEDWINDOW,   // Hint type
        (uint)sizeof(RECT),         // Data size
        (byte*)&hintRect);          // Hint data (RECT)
}

// Clear a render hint
unsafe {
    PInvoke.WTSSetRenderHint(&hintId, hWnd, RENDER_HINT_CLEAR, 0, null);
}

Hint Types:

  • RENDER_HINT_MAPPEDWINDOW - Associates a rectangular region with a window
  • RENDER_HINT_CLEAR - Removes a previously set hint

Server Workflow:

  1. Create a "render window" to anchor the hint
  2. Calculate hint rectangle (e.g., 80% of client area, centered)
  3. Call WTSSetRenderHint() with RENDER_HINT_MAPPEDWINDOW
  4. Include the returned hintId in ping messages to client
  5. Clear hint before window destruction or on disconnect

Thread Safety: Render hint operations are protected by a lock since the hint ID must be atomically created and stored.

Bitmap Renderer Context (Client-Side)

Purpose: Render custom bitmap content directly into the RDP client window, overlaying a region that the server has designated via a render hint. The client-side bitmap renderer runs in the RDP client process context and is only available while an RDP session is connected.

What is the "context": Each IWTSBitmapRenderer represents a rendering context for one specific mapped region. The context is tied to the render hint ID issued by the server — that ID is the key linking the server's window region to the client's render surface. The client draws frames into this context; the RDP stack composites them onto the corresponding part of the remote desktop display.

API: IWTSBitmapRenderService, IWTSBitmapRenderer, IWTSBitmapRendererCallback

Documentation Links:

Acquiring the Service (in IWTSPlugin.Initialize):

The bitmap render service is obtained via IWTSPluginServiceProvider (acquired by casting the IWTSVirtualChannelManager passed to Initialize). It is only available on supported RDP client builds.

// IWTSVirtualChannelManager may implement IWTSPluginServiceProvider
var serviceProvider = channelManager as IWTSPluginServiceProvider;

// Get the bitmap render service
serviceProvider.GetService(PInvoke.RDCLIENT_BITMAP_RENDER_SERVICE, out object? service);
var bitmapService = service as IWTSBitmapRenderService;

Creating a Renderer Context (for a given hint ID):

// Create renderer for a specific mapping ID (from server's render_hint_id)
bitmapService.GetMappedRenderer(mappingId, this, out var rendererObj);
var renderer = (IWTSBitmapRenderer)rendererObj;

// Render a frame: BGRA32 pixel data, bottom-up row order (standard Windows DIB)
int stride = width * 4; // 4 bytes per pixel (BGRA32)
renderer.Render(
    Guid.Empty,         // Reserved, pass Guid.Empty
    (uint)width,        // Frame width in pixels
    (uint)height,       // Frame height in pixels
    stride,             // Row stride in bytes (width * 4)
    (uint)bitmapSize,   // Total buffer size in bytes
    bitmapBuffer);      // BGRA32 pixel buffer

// Remove renderer when done (e.g., on disconnect or channel close)
renderer.RemoveMapping();

Pixel Format: BGRA32 (4 bytes per pixel: Blue, Green, Red, Alpha), bottom-up row order. The stride is width * 4. The render surface dimensions are determined by the server's hint rectangle and are communicated to the plugin via IWTSBitmapRendererCallback.OnTargetSizeChanged.

BitmapRendererManager Class:

The BitmapRendererManager class in rdp-plugin-client-common provides:

  • Renderer Pool: Persistent collection of renderers that survive disconnect/reconnect
  • Per-Renderer Tasks: Each renderer runs its own render loop at 30fps
  • Rainbow Animation: Demo content showing scrolling rainbow pattern
  • Pause/Resume: Stop rendering on disconnect, resume on reconnect
  • Thread-Safe: Lock-protected pool with atomic pause flag

Key Methods:

// Handle render hint from server message
void HandleRenderHint(ulong renderHintId);

// Lifecycle management
void OnDisconnected();          // Pause all rendering
void OnConnected();             // Resume all rendering
void CleanupAllRenderers();     // Final cleanup (Terminated/Dispose)

// IWTSBitmapRendererCallback
void OnTargetSizeChanged(RECT rcNewSize);  // Called when target size changes

Renderer Task Lifecycle:

  1. HandleRenderHint() called with hint ID from server message
  2. Get or create renderer from pool for that mapping ID
  3. Start a Task for the render loop (thread pool)
  4. Task runs: check pause flag → generate BGRA frame → Render() → sleep ~33ms
  5. On removal: signal cancellation, await task, release renderer

End-to-End Flow

Server (rdp-plugin-server)              Client (rdp-plugin-client-inprocserver)
─────────────────────────              ──────────────────────────────────────
1. Create render window
2. Calculate hint rect (80% centered)
3. WTSSetRenderHint(MAPPEDWINDOW)
4. Get hintId back
   │
   ├─── PingMessage { render_hint_id } ────────>
   │                                           5. Parse render_hint_id
   │                                           6. BitmapRendererManager.HandleRenderHint(id)
   │                                           7. GetMappedRenderer(id) creates renderer
   │                                           8. Render task starts (30fps rainbow)
   │
   <──── EchoMessage (same content) ───────────
   │
[window resize]
9. UpdateRenderHintFromClientRect()
10. WTSSetRenderHint (update rect)
   │
   ├─── PingMessage { same hint_id } ─────────>
   │                                           11. OnTargetSizeChanged(newRect)
   │                                           12. Render adapts to new size
   │
[disconnect]
13. ClearRenderHint()
14. Close channel                              15. OnDisconnected() - pause rendering
   │                                               (renderers kept in pool)
   │
[reconnect]
15. Open channel
16. New WTSSetRenderHint() → new hintId
   │
   ├─── PingMessage { new_hint_id } ──────────>
   │                                           17. OnConnected() - resume rendering
   │                                           18. Create new renderer for new ID
   │                                               (old renderers cleaned up)

Shared Infrastructure

rdp-plugin-client-common Library

Purpose: Reusable components for building RDP client plugins that communicate via Dynamic Virtual Channels. Eliminates code duplication across different COM activation models.

Core Features:

  • RdpPluginBase class: Abstract base implementing all required RDP plugin interfaces
  • BitmapRendererManager class: Manages bitmap renderers with dedicated tasks
  • COM infrastructure: Generic IClassFactory (BasicClassFactory), thread-safe reference counting
  • Utilities: Logging helpers, registry helpers

Interfaces Implemented (by RdpPluginBase):

  • IWTSPlugin - Plugin lifecycle (Initialize, Terminated)
  • IWTSListenerCallback - New channel notifications (OnNewChannelConnection)
  • IWTSVirtualChannelCallback - Data I/O events (OnDataReceived, OnClose) - implemented by inner ChannelCallback class

Interfaces Implemented (by BitmapRendererManager):

  • IWTSBitmapRendererCallback - Target size change notifications (OnTargetSizeChanged)

RdpPluginBase Class:

public abstract class RdpPluginBase :
    IWTSPlugin,
    IWTSListenerCallback,
    IDisposable
{
    protected const string DvcChannelName = "dvc::sample::advancedplugin";

    // Called when data arrives from server
    protected virtual void OnDataReceived(ReadOnlySpan<byte> data);
}

Key Methods:

Initialize(IWTSVirtualChannelManager pChannelMgr)

  • Called by RDP client to initialize plugin
  • Creates listener for "dvc::sample::advancedplugin" channel
  • Returns S_OK on success

OnNewChannelConnection(IWTSVirtualChannel pChannel, ...)

  • Called when server opens channel from remote session
  • Stores channel pointer for Write operations
  • Returns IWTSVirtualChannelCallback interface (inner ChannelCallback)

OnDataReceived(ReadOnlySpan<byte> data)

  • Called when data arrives from server
  • Reassembles fragmented messages
  • Parses framed protobuf message
  • Echoes data back via IWTSVirtualChannel::Write

BasicClassFactory Class:

  • Generic IClassFactory implementation for creating COM objects
  • Thread-safe reference counting
  • Module lock management (prevents DLL unload while objects exist)
  • Proper QueryInterface with interface validation

rdp-plugin-protocol Library

Purpose: Common message format and serialization layer for communication between RDP client plugins and server-side applications. Ensures protocol compatibility.

Message Definitions:

syntax = "proto3";
package rdp.plugin.protocol;

message PingMessage {
    uint32 sequence_number = 1;    // Incrementing counter for tracking
    int64 utc_ticks = 2;           // Timestamp (100ns since Unix epoch)
    bytes payload = 3;             // Data bytes for echo testing
    uint64 render_hint_id = 4;     // Window/region identifier
    uint32 render_hint_type = 5;   // Hint type (e.g., MAPPEDWINDOW)
}

message FrameHeader {
    uint32 data_length = 1;
}

Framing Format:

Offset  Bytes               Description
------  --------            -----------
0x0000  0C 02 00 00         Length = 524 (0x020C) little-endian
0x0004  08 01 10 ...        Protobuf payload (524 bytes)

PingMessageHelper Class:

Constants:

public const int FrameHeaderSize = 4;        // Length prefix size
public const int DefaultPayloadSize = 5120;  // 5 KB default
public const int MaxPayloadSize = 16777216;  // 16 MB limit

Key Methods:

  • CreatePingMessage() - Factory with default payload generation
  • SerializeWithFraming() - Adds length prefix to protobuf bytes
  • TryParseFramed<T>() - Validates and parses framed messages

Client Plugins

rdp-plugin-client-inprocserver

Activation: In-process COM server via .NET COM hosting

When to use:

  • Production deployments requiring maximum performance
  • Tight integration with RDP client process
  • Standard Windows COM activation pattern

Project Configuration:

<EnableComHosting>true</EnableComHosting>

Build Output:

  • rdp-plugin-client-inprocserver-csharp.dll - Managed assembly
  • rdp-plugin-client-inprocserver-csharp.comhost.dll - Native COM shim

Loading Sequence:

  1. RDP client reads AddIns registry key → finds CLSID {D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}
  2. RDP client calls CoCreateInstance(CLSID, ...)
  3. COM looks up CLSID in registry → finds InprocServer32 path to *.comhost.dll
  4. COM calls LoadLibrary("rdp-plugin-client-inprocserver-csharp.comhost.dll")
  5. comhost loads the .NET runtime and managed assembly
  6. COM calls DllGetClassObject() → gets IClassFactory
  7. COM calls factory->CreateInstance() → creates RdpInprocPlugin
  8. RDP client calls plugin->Initialize() → creates listener

rdp-plugin-client-loadlibrary

Activation: Direct DLL loading via VirtualChannelGetInstance export

Project Configuration:

<PublishAot>true</PublishAot>
<NativeLib>Shared</NativeLib>

Entry Point:

[UnmanagedCallersOnly(EntryPoint = "VirtualChannelGetInstance")]
public static int VirtualChannelGetInstance(
    Guid* requestedIid,
    uint* pcObj,
    nint* ppObjArray)

Two-Phase Call Pattern:

Phase 1 - Discovery (ppObjArray == null):

pcObj  = 0  (in)  → set to 1  (out)
result = S_OK

Phase 2 - Creation (ppObjArray != null):

pcObj     = 1  (in)  → plugin count
ppObjArray = null (in)  → receives IWTSPlugin* pointer
result = S_OK

Activation: Out-of-process COM server (standalone EXE)

  • Development and testing (easier debugging)
  • Process isolation from RDP client
  • Separate privilege requirements
  • Independent lifecycle from client

COM LocalServer Pattern:

  1. COM launches EXE when first client calls CoCreateInstance
  2. EXE registers class factory via CoRegisterClassObject
  3. COM creates plugin instance via factory (cross-process marshaling)
  4. EXE stays running while plugin objects exist
  5. EXE revokes class factory and exits when no more clients

Process Lifetime:

  • Startup: COM launches EXE automatically
  • Active: Message pump or wait on event
  • Shutdown: Last plugin reference released → EXE exits

Server Application

rdp-plugin-server

Purpose: Windows program that runs inside RDP session and communicates with client plugins via Dynamic Virtual Channels.

Core Features:

  • WTS API for DVC channel management
  • Session state change notifications
  • Render hints for window association
  • Two I/O modes: synchronous (WTS API) and asynchronous (file handle)
  • Survives disconnect/reconnect cycles
  • Graceful shutdown on multiple exit events

Command-Line Options

rdp-plugin-server-csharp.exe [options]

Options:
  /filehandle, /f    Use asynchronous file-handle I/O instead of WTS API (default: WTS)
  /help, /?          Show help

Startup Sequence

  1. Parse command-line options (/filehandle)
  2. Create exit cancellation token
  3. Install console control handler (Ctrl+C)
  4. Instantiate RdpPluginServerClient and call Run()

Run Sequence

  1. CreateMessageWindow() - Hidden window for WM_WTSSESSION_CHANGE
  2. RegisterSessionNotifications() - Subscribe to session state events
  3. CheckAndOpenChannel() - Open channel if already in connected session
  4. MessageLoop() - Process window messages until cancellation requested
  5. After exit:
    • CloseChannel() - Cleanup DVC, destroy render window
    • DestroyMessageWindow() - Unregister session notifications

Session State Management

On connect (WTS_REMOTE_CONNECT / WTS_SESSION_LOGON):

  1. Close any stale channel
  2. Open new channel (WTSVirtualChannelOpenEx)
  3. Start send/receive loop

On disconnect (WTS_REMOTE_DISCONNECT / WTS_SESSION_LOGOFF):

  1. Close channel immediately
  2. Cancel send/receive tasks
  3. App continues running, waiting for reconnect (no exit)

Two I/O Modes

WTS API Mode (default):

  • Synchronous WTSVirtualChannelRead/WTSVirtualChannelWrite
  • Single thread handles both send and receive
  • Simpler implementation

File Handle Mode (/filehandle):

  • WTSVirtualChannelQuery(WTSVirtualFileHandle) to get underlying file handle
  • Asynchronous OVERLAPPED ReadFile/WriteFile
  • Separate read and write threads
  • Manual-reset events for completion notification
  • Better performance under high throughput

Exit Methods

  • Press Ctrl+C in console
  • Close the render window

Plugin Registration

All client plugins use the same COM CLSID: {D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}

InprocServer Registration

HKLM (or HKCU)\Software\Microsoft\Terminal Server Client\Default\AddIns\AdvancedRdpPlugin\
    Name = "{D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}"

HKLM (or HKCU)\Software\Classes\CLSID\{D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}
    (Default) = "RDP Dynamic Virtual Channel Plugin (InprocServer)"
    InprocServer32\
        (Default) = "C:\path\to\rdp-plugin-client-inprocserver-csharp.comhost.dll"
        ThreadingModel = "Free"

The AddIns entry (with the CLSID as the Name value) is how the RDP client discovers the plugin and calls CoCreateInstance. It is written by the [ComRegisterFunction] in RdpInprocPlugin.cs during regsvr32.

Register with:

regsvr32 rdp-plugin-client-inprocserver-csharp.comhost.dll

LoadLibrary Registration

regsvr32 bin\x64\Debug\native\rdp-plugin-client-loadlibrary-csharp.dll

This registers both:

  1. CLSID/InprocServer32 pointing to rdp-plugin-client-inprocserver-csharp.comhost.dll (allows VirtualChannelGetInstance to call CoCreateInstance internally)
  2. AddIns key with the native DLL path for RDP client discovery:
HKLM\Software\Microsoft\Terminal Server Client\Default\AddIns\AdvancedRdpPlugin\
    Name = "C:\\path\\to\\rdp-plugin-client-loadlibrary-csharp.dll"

Note: Unlike traditional COM registration, the LoadLibrary AddIns entry stores the DLL path directly. The RDP client loads the DLL and calls the exported VirtualChannelGetInstance function directly.

LocalServer Registration

HKLM (or HKCU)\Software\Microsoft\Terminal Server Client\Default\AddIns\AdvancedRdpPlugin\
    Name = "{D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}"

HKLM (or HKCU)\Software\Classes\CLSID\{D8B80669-C06A-4BD1-9CB1-3B7168C9E3A3}
    LocalServer32\
        (Default) = "C:\path\to\rdp-plugin-client-localserver-csharp.exe"

The AddIns entry (with the CLSID as the Name value) is how the RDP client discovers the plugin and calls CoCreateInstance. The LocalServer32 entry is how COM launches the EXE on activation. Both are written by /register.

Register with:

# Per-user registration (default)
bin\x64\Debug\rdp-plugin-client-localserver-csharp.exe /register

# Machine-wide registration (requires admin)
bin\x64\Debug\rdp-plugin-client-localserver-csharp.exe /register /machine

# Unregister
bin\x64\Debug\rdp-plugin-client-localserver-csharp.exe /unregister

Running the sample

  1. Build all projects:

    .\build.ps1
    
  2. Register a client plugin (choose one):

    # InprocServer (recommended)
    regsvr32 bin\x64\Debug\rdp-plugin-client-inprocserver-csharp.comhost.dll
    
    # LocalServer (for testing/debugging)
    bin\x64\Debug\rdp-plugin-client-localserver-csharp.exe /register
    
  3. Run server inside RDP session:

    bin\x64\Debug\rdp-plugin-server-csharp.exe
    
  4. Connect via RDP client to the session:

    # Use any supported RDP client:
    # - mstsc.exe (built-in Windows Remote Desktop Connection)
    # - Windows App
    # - Remote Desktop Client ActiveX control
    
  5. Observe output:

    • Server console shows RTT measurements
    • Client plugin echoes data back
    • Disconnect/reconnect works seamlessly

.NET-Specific Considerations

CsWin32 Source Generator

Windows API interop is generated at compile time using CsWin32:

NativeMethods.txt defines which APIs to generate:

IWTSPlugin
IWTSVirtualChannelManager
IWTSVirtualChannel
IWTSListener
IWTSListenerCallback
IWTSVirtualChannelCallback
IWTSPluginServiceProvider

IWTSBitmapRendererCallback
IWTSBitmapRenderer
IWTSBitmapRenderService
RDCLIENT_BITMAP_RENDER_SERVICE

RECT

Note: WTSVirtualChannelOpenEx, WTSSetRenderHint, and other WTS server-side APIs are not generated by CsWin32 in this project; the server application (rdp-plugin-server) uses manual [DllImport] P/Invoke declarations instead (see NativeMethods.cs in that project).

NativeMethods.json configures generation options.

This produces strongly-typed P/Invoke wrappers with proper marshaling, replacing hand-written [DllImport] declarations.

COM Interop in .NET 8

  • [ComVisible(true)] exposes classes to COM
  • [Guid("...")] assigns CLSIDs and IIDs
  • [ClassInterface(ClassInterfaceType.None)] prevents auto-generated interfaces
  • [ComRegisterFunction] / [ComUnregisterFunction] - called by regsvr32 for custom registration logic (used by inprocserver to register the comhost DLL path, not the managed DLL)

Native AOT Considerations

For the LoadLibrary plugin:

  • [UnmanagedCallersOnly] exports functions without managed calling convention overhead
  • COM object lifetime is managed via ComWrappers to bridge native reference counting with the GC
  • GC handles prevent premature collection of managed objects pinned for COM
  • Static state must be carefully managed (no AppDomain isolation)

Thread Safety

All implementations use proper synchronization:

  • Locks: Protect channel state, renderer pool, render hint state, sequence counters
  • Atomic operations: Boolean flags for pause/exit states
  • OVERLAPPED I/O: File handle mode uses separate read/write threads with manual-reset events
  • COM Threading: InprocServer uses ThreadingModel = Free (set by [ComRegisterFunction] during regsvr32)

What This Demonstrates

Core RDP/DVC Concepts

  • Opening dynamic virtual channels in RDP sessions
  • Handling fragmented DVC messages (CHANNEL_FLAG_FIRST/LAST)
  • Session state change notifications (connect/disconnect/logon/logoff)
  • Two I/O patterns: WTS API (sync) vs file handle (async OVERLAPPED)

RDP Graphics APIs

  • Render Hints (Server): WTSSetRenderHint() for content-window association
  • Bitmap Renderer (Client): IWTSBitmapRenderer for custom client-side rendering
  • End-to-end hint ID flow from server to client
  • Persistent renderer pool surviving disconnect/reconnect cycles
  • Per-renderer tasks with 30fps animation

.NET COM Patterns

  • Three activation models: InprocServer32 (comhost), LoadLibrary (Native AOT), LocalServer32
  • CsWin32 for type-safe Windows API access at compile time
  • Native AOT for native DLL export via [UnmanagedCallersOnly]
  • [ComRegisterFunction] / [ComUnregisterFunction] for custom regsvr32 logic
  • Module lock counting to prevent premature DLL unload

Message Protocol

  • Protocol Buffers for cross-language compatibility
  • Length-prefixed framing for stream parsing
  • RTT measurement for performance analysis
  • Payload echo for bidirectional testing

Next steps

Official Microsoft Documentation

RDP Virtual Channels:

Bitmap Renderer API (Client-Side):

Render Hint API (Server-Side):

COM Development:

Session Management:

.NET COM Interop:

CsWin32:

Protocol Buffers:

License

See repository root for license information.