1. Architectural Overview & Plugin Lifecycle
Uniflow uses a modern, dynamic plugin architecture based on .NET 8's collectible AssemblyLoadContext (ALC). Host applications separate backend execution (UniflowService) from user interaction (Uniflow GUI).
Centralized Management: All plugin discovery, validation, licensing, loading, and execution reside exclusively in backend UniflowService. There is no local PluginManager inside the GUI client.
Remote UI Delivery: Custom WPF editor controls and ViewModels are dynamically pushed from UniflowService to the client UI on-demand over IPC via GetPluginUiBundle.
┌─────────────────────────────────────────────────────────────────────────────┐
│ UNIFLOW PLUGIN ARCHITECTURE │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ Uniflow GUI │ │ UniflowService │
│ - Dynamic PluginUiProvider │ │ - Central PluginManager │
│ - ClientPluginSessionManager │ │ - Collectible ALC Sandbox │
│ - Remote WPF Control Hosting │ │ - SourceManager & Adapters │
└───────────────┬────────────────┘ └───────────────┬────────────────┘
│ │
│ Named Pipe IPC │
│ - GetPluginUiBundle │
│ - Binary UI Assembly Delivery │
└───────────────────►◄───────────────────┘
│
Reads %ProgramData%\Uniflow\Plugins\
▼
┌───────────────────────────────────────┐
│ Custom Plugin Bundle (.zip) │
│ ├── plugin.json (Manifest) │
│ ├── MyPlugin.dll (WPF + Logic) │
│ └── icon.png (256x256 Display Icon) │
└───────────────────────────────────────┘
Core Architectural Principles
- Service-Centric Management:
UniflowServicemanages all plugin sandboxes inside collectibleAssemblyLoadContextinstances. - On-Demand Remote Delivery: Opening a plugin's settings in the UI triggers an IPC request (
GetPluginUiBundle). The service streams the UI assembly bytes and dependencies directly to the GUI. - Client Session Hosting: The GUI uses
ClientPluginSessionManagerto load the delivered UI assembly into a light isolated ALC session, instantiating the*EditorViewand*EditorViewModeldynamically. - Dual Execution Bundle: A single compiled ZIP package contains backend service engine logic (
ISourceAdapter) and frontend configuration controls (UserControl+ISourceEditorViewModel).
2. Visual Studio Project Setup
Create a new C# Class Library project targeting .NET 8.0 Windows with WPF enabled.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Uniflow.Plugin.MyCustomPlugin</AssemblyName>
<RootNamespace>Uniflow.Plugin.MyCustomPlugin</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Core Uniflow Host Contracts -->
<Reference Include="UniflowLibs">
<HintPath>..\bin\UniflowLibs.dll</HintPath>
<Private>false</Private> <!-- Must be false to use host runtime assemblies -->
</Reference>
<Reference Include="UniflowService">
<HintPath>..\bin\UniflowService.exe</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- UI & MVVM Libraries -->
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="WPF-UI" Version="3.0.4" />
</ItemGroup>
<ItemGroup>
<!-- Embed icon and manifest -->
<Content Include="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="icon.png" />
</ItemGroup>
</Project>
Always set <Private>false</Private> on host reference DLLs (UniflowLibs, UniflowService). This ensures contracts resolve from the host application context at runtime rather than duplicating inside the plugin's local ALC sandbox.
3. Declare the plugin.json Manifest
Every plugin requires a plugin.json manifest file in its root directory. This manifest informs UniflowService's PluginManager about metadata, entry assemblies, categories, and licensing.
{
"Id": "uniflow.plugin.mycustomplugin",
"Name": "My Custom Protocol",
"Type": "Industrial Protocols",
"Version": "1.0.0",
"Author": "Acme Engineering Team",
"Description": "Integrates proprietary field telemetry controllers over TCP/IP sockets.",
"SourceType": "uniflow.plugin.mycustomplugin",
"EntryAssembly": "Uniflow.Plugin.MyCustomPlugin.dll",
"MinSystemVersion": "1.0.0",
"LicensePlan": "Free"
}
Field Definitions
| Parameter | Type | Required | Description |
|---|---|---|---|
Id |
String | Yes | Unique reverse-DNS identifier (e.g. uniflow.plugin.mycustomplugin). |
Name |
String | Yes | Human-readable title displayed across the UI. |
Type |
String | Yes | Category for filtering (e.g. Industrial Protocols, Security & Video, Custom / Other). |
Version |
String | Yes | Semantic versioning format (e.g. 1.0.0). |
Author |
String | No | Developer or vendor organization name. |
Description |
String | Yes | Detailed overview of capabilities. |
SourceType |
String | Yes | String identifier matching SourceTypeId. |
EntryAssembly |
String | Yes | Filename of main compiled assembly DLL inside package. |
LicensePlan |
String | Yes | Free or PRO license requirement. |
4. Implement Service Adapter & Factory
The service adapter runs on UniflowService to handle protocol communication, hardware socket polling, data transformation, tag catalog generation, and command execution.
Plugin source adapters MUST cancel background polling loops, dispose active timers (PeriodicTimer), close network sockets/channels, and implement IDisposable (or IAsyncDisposable). Complete resource cleanup in OnStopAsync / Dispose ensures that .NET 8's collectible AssemblyLoadContext unloads plugin instances cleanly without memory leaks when plugins are updated.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UniflowLibs.Contracts.Sources;
using UniflowService.Sources;
using UniflowService.Sources.Abstractions;
namespace Uniflow.Plugin.MyCustomPlugin;
public class MyCustomSourceAdapter : SourceAdapterBase, IDisposable
{
private string _host = "127.0.0.1";
private int _port = 5000;
private int _pollingIntervalMs = 1000;
private CancellationTokenSource? _cts;
private PeriodicTimer? _timer;
private Task? _pollTask;
public MyCustomSourceAdapter(SourceDto definition, SourceCapabilities capabilities)
: base(definition, capabilities)
{
ParseSettings(definition);
}
private void ParseSettings(SourceDto definition)
{
if (definition.Settings == null) return;
if (definition.Settings.TryGetValue("Host", out var h) && !string.IsNullOrEmpty(h)) _host = h;
if (definition.Settings.TryGetValue("Port", out var p) && int.TryParse(p, out var portVal)) _port = portVal;
if (definition.Settings.TryGetValue("PollingIntervalMs", out var rate) && int.TryParse(rate, out var rateVal)) _pollingIntervalMs = rateVal;
}
protected override async Task OnStartAsync(CancellationToken ct)
{
await SetConnectionStateAsync(SourceConnectionState.Connecting, $"Connecting to {_host}:{_port}...");
_cts = new CancellationTokenSource();
_timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_pollingIntervalMs));
await SetConnectionStateAsync(SourceConnectionState.Connected, $"Connected to {_host}:{_port}");
_pollTask = RunPollLoopAsync(_cts.Token);
}
protected override async Task OnStopAsync(CancellationToken ct)
{
if (_cts != null)
{
_cts.Cancel();
_timer?.Dispose();
if (_pollTask != null)
{
try { await _pollTask.ConfigureAwait(false); } catch { }
}
_cts.Dispose();
_cts = null;
}
await SetConnectionStateAsync(SourceConnectionState.Disconnected, "Adapter stopped.");
}
public void Dispose()
{
OnStopAsync(CancellationToken.None).GetAwaiter().GetResult();
GC.SuppressFinalize(this);
}
protected override Task<SourceTestResult> OnTestConnectionAsync(CancellationToken ct)
{
return Task.FromResult(new SourceTestResult
{
Success = true,
Message = $"Successfully pinged {_host}:{_port}"
});
}
protected override Task<SourceCatalogSnapshot> OnBrowseCatalogAsync(bool forceRefresh, CancellationToken ct)
{
var catalog = BuildEmptyCatalogSnapshot();
catalog.Items.Add(new SourceItemDescriptor
{
Id = "sensor_temp",
DisplayName = "Ambient Temperature",
Path = "Sensors/Temperature",
Category = "Telemetry",
Kind = SourceItemKind.Value,
Access = SourceItemAccess.Read,
ValueType = SourceValueType.Double,
Unit = "°C",
IsBrowsable = true,
IsCached = true,
SupportsMonitoring = true
});
catalog.Items.Add(new SourceItemDescriptor
{
Id = "relay_state",
DisplayName = "Relay Output Switch",
Path = "Control/Relay1",
Category = "Actuators",
Kind = SourceItemKind.Value,
Access = SourceItemAccess.ReadWrite,
ValueType = SourceValueType.Bool,
IsBrowsable = true,
IsCached = true,
SupportsMonitoring = true
});
return Task.FromResult(catalog);
}
protected override Task<SourceValuesSnapshot> OnRefreshValuesAsync(IReadOnlyCollection<string>? itemIds, CancellationToken ct)
{
var snapshot = BuildEmptyValuesSnapshot(fullSnapshot: false);
snapshot.Values.Add(new SourceItemValue
{
ItemId = "sensor_temp",
Timestamp = DateTimeOffset.UtcNow,
Value = SourceValueData.FromDouble(23.45)
});
snapshot.Values.Add(new SourceItemValue
{
ItemId = "relay_state",
Timestamp = DateTimeOffset.UtcNow,
Value = SourceValueData.FromBool(true)
});
return Task.FromResult(snapshot);
}
public override Task<bool> WriteValueAsync(string itemId, SourceValueData value, CancellationToken ct)
{
if (itemId == "relay_state")
{
bool state = value.BoolValue ?? false;
// Write payload to hardware device...
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
5. Design WPF Configuration UI
Plugins provide a native WPF configuration interface for setting host IP addresses, ports, polling frequencies, and credentials.
Because the GUI client receives UI controls dynamically over IPC via GetPluginUiBundle, ViewModel constructors must accept parameters injected by PluginUiProvider (such as IIpcClient?, SourceDto, and sourceType).
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System;
using System.Threading.Tasks;
using UniflowLibs.Contracts.Ipc;
using UniflowLibs.Contracts.Sources;
namespace Uniflow.Plugin.MyCustomPlugin;
public partial class MyCustomSourceEditorViewModel : ObservableObject, ISourceEditorViewModel
{
private readonly IIpcClient? _ipc;
private readonly SourceDto _source;
[ObservableProperty] private string _host = "127.0.0.1";
[ObservableProperty] private int _port = 5000;
[ObservableProperty] private int _pollingIntervalMs = 1000;
[ObservableProperty] private string _statusMessage = "";
public string SourceType { get; }
public IAsyncRelayCommand SaveCommand { get; }
public IRelayCommand CancelCommand { get; }
public IAsyncRelayCommand TestConnectionCommand { get; }
public event Action? Saved;
public event Action? Cancelled;
// Constructor resolved by Uniflow GUI's PluginUiProvider
public MyCustomSourceEditorViewModel(IIpcClient? ipc, SourceDto source, string sourceType)
{
_ipc = ipc;
_source = source;
SourceType = sourceType;
if (source.Settings != null)
{
if (source.Settings.TryGetValue("Host", out var h)) Host = h;
if (source.Settings.TryGetValue("Port", out var p) && int.TryParse(p, out var portVal)) Port = portVal;
if (source.Settings.TryGetValue("PollingIntervalMs", out var rate) && int.TryParse(rate, out var rateVal)) PollingIntervalMs = rateVal;
}
SaveCommand = new AsyncRelayCommand(OnSaveAsync);
CancelCommand = new RelayCommand(() => Cancelled?.Invoke());
TestConnectionCommand = new AsyncRelayCommand(OnTestConnectionAsync);
}
private async Task OnSaveAsync()
{
if (_ipc == null) return;
_source.Settings ??= new();
_source.Settings["Host"] = Host;
_source.Settings["Port"] = Port.ToString();
_source.Settings["PollingIntervalMs"] = PollingIntervalMs.ToString();
var resp = await _ipc.SendAsync(new IpcRequestEnvelope
{
Command = IpcCommand.UpsertSource,
Payload = new UpsertSourceRequest { Source = _source },
ClientId = "Uniflow.GUI"
}, default);
if (resp.Code == IpcResultCode.Ok)
Saved?.Invoke();
else
StatusMessage = $"Save Failed: {resp.ErrorMessage}";
}
private async Task OnTestConnectionAsync()
{
if (_ipc == null) return;
StatusMessage = "Testing connection...";
var resp = await _ipc.SendAsync(new IpcRequestEnvelope
{
Command = IpcCommand.TestSourceConnection,
Payload = new TestSourceConnectionRequest { Source = _source },
ClientId = "Uniflow.GUI"
}, default);
StatusMessage = resp.Code == IpcResultCode.Ok ? "Connection successful!" : $"Connection failed: {resp.ErrorMessage}";
}
}
<UserControl x:Class="Uniflow.Plugin.MyCustomPlugin.MyCustomSourceEditorView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
MinWidth="420">
<StackPanel Margin="15">
<TextBlock Text="Custom Protocol Configuration" FontSize="18" FontWeight="Bold" Margin="0,0,0,15" Foreground="White"/>
<TextBlock Text="Target Host / IP Address:" Margin="0,5" Foreground="LightGray"/>
<ui:TextBox Text="{Binding Host, UpdateSourceTrigger=PropertyChanged}" PlaceholderText="e.g. 192.168.1.100"/>
<TextBlock Text="TCP Port:" Margin="0,12,0,5" Foreground="LightGray"/>
<ui:NumberBox Value="{Binding Port, UpdateSourceTrigger=PropertyChanged}" Minimum="1" Maximum="65535"/>
<TextBlock Text="Polling Interval (ms):" Margin="0,12,0,5" Foreground="LightGray"/>
<ui:NumberBox Value="{Binding PollingIntervalMs, UpdateSourceTrigger=PropertyChanged}" Minimum="100" Maximum="60000"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="Teal" Margin="0,10" TextWrapping="Wrap"/>
</StackPanel>
</UserControl>
6. Helper Windows for Catalog & Topic Exploration in Plugin Editors
Complex enterprise plugins (such as Avigilon and Milestone) require custom WPF helper windows inside their settings editor to allow system integrators to visually browse, search, and configure topics, subtopics, event categories, or security channels.
┌─────────────────────────────────────────────────────────────────────────────┐
│ PLUGIN EDITOR HELPER DIALOG FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────┐ ┌───────────────────────────┐
│ Source Editor View │ Click Button │ Topic/Catalog Helper │
│ [ Configure Topics... ] ├────────────────►│ Configuration Window │
└──────────────┬───────────────┘ └─────────────┬─────────────┘
│ │
│ Categorized │ Filter & Select
│ Trees │ Atomic Events
│ ▼
│ Save & Return CSV Topics ┌────────────────────────────┐
│◄───────────────────────────────┤ Normalized Topic Selection │
│ └────────────────────────────┘
▼
Saved into SourceDto.Settings["Topics"]
Topic & Catalog Bucket Pattern Example
In the Avigilon plugin, AvigilonTopicsConfigurationViewModel categorizes complex Socket.IO event subscriptions into hierarchical buckets (e.g. DEVICE, ALARM, ACCESS_CONTROL) and formats selected atomic events into clean CSV strings for SourceDto.Settings:
public partial class AvigilonTopicsConfigurationViewModel : ObservableObject
{
[ObservableProperty] private MainTopicItem? _selectedMainTopic;
[ObservableProperty] private BucketItem? _selectedBucket;
[ObservableProperty] private string _searchAtomicEventsText = string.Empty;
public ObservableCollection<MainTopicItem> MainTopics { get; } = new();
public ObservableCollection<BucketItem> Buckets { get; } = new();
// Filter atomic events dynamically as user types search text
public IEnumerable<AtomicEventItem> FilteredAtomicEvents =>
SelectedBucket == null ? Array.Empty<AtomicEventItem>() :
string.IsNullOrWhiteSpace(SearchAtomicEventsText) ? SelectedBucket.AtomicEvents :
SelectedBucket.AtomicEvents.Where(e => e.Name.Contains(SearchAtomicEventsText, StringComparison.OrdinalIgnoreCase));
// Formats checked atomic events into clean CSV strings for SourceDto.Settings
public string GetSelectedSubtopicsCsv()
{
var resultList = new List<string>();
foreach (var bucket in Buckets)
{
if (bucket.IsSelected)
{
var checkedChildren = bucket.AtomicEvents.Where(c => c.IsSelected).ToList();
if (checkedChildren.Count > 0)
resultList.AddRange(checkedChildren.Select(c => c.Name));
else
resultList.Add(bucket.Name);
}
}
return string.Join(",", resultList);
}
}
7. Implement ISourcePluginDefinition Entry Point
ISourcePluginDefinition (UniflowLibs.Contracts.Plugins) is the root contract discovered by UniflowService's PluginManager via reflection when discovering and registering plugins.
using System;
using System.Collections.Generic;
using System.Windows.Controls;
using UniflowLibs.Contracts.Plugins;
using UniflowLibs.Contracts.Sources;
using UniflowLibs.Contracts.Ipc;
using UniflowService.Sources.Abstractions;
namespace Uniflow.Plugin.MyCustomPlugin;
public class MyCustomPluginDefinition : ISourcePluginDefinition
{
public string SourceTypeId => "uniflow.plugin.mycustomplugin";
public string Name => "My Custom Protocol";
public string Description => "Integrates proprietary field telemetry controllers over TCP/IP sockets.";
public byte[] SourceImage => GetEmbeddedIconBytes();
public ISourceAdapterFactory CreateAdapterFactory(IServiceProvider sp)
{
return new MyCustomSourceAdapterFactory(SourceTypeId);
}
public object CreateEditorViewModel(IServiceProvider sp, SourceDto? source)
{
var ipc = (IIpcClient?)sp.GetService(typeof(IIpcClient));
return new MyCustomSourceEditorViewModel(ipc, source ?? new SourceDto { SourceType = SourceTypeId }, SourceTypeId);
}
public Type EditorViewType => typeof(MyCustomSourceEditorView);
public PluginRuleIntegration? RuleIntegration => new PluginRuleIntegration
{
FieldDescriptors = new List<RuleFieldDescriptor>
{
new RuleFieldDescriptor
{
TargetItemId = "relay_state",
ArgumentKey = "Value",
EditorKind = RuleFieldEditorKind.Boolean
}
}
};
private byte[] GetEmbeddedIconBytes()
{
using var stream = GetType().Assembly.GetManifestResourceStream("Uniflow.Plugin.MyCustomPlugin.icon.png");
if (stream == null) return Array.Empty<byte>();
var bytes = new byte[stream.Length];
stream.Read(bytes, 0, bytes.Length);
return bytes;
}
}
8. Rules Engine Integration & Custom Field Descriptors
Plugins customize how their items behave inside the Rule Editor property panel by declaring a PluginRuleIntegration object.
public sealed class PluginRuleIntegration
{
public List<RuleFieldDescriptor> FieldDescriptors { get; set; } = new();
public List<string> EventOnlyItemIds { get; set; } = new();
public List<string> DataOnlyItemIds { get; set; } = new();
}
public sealed class RuleFieldDescriptor
{
public string TargetItemId { get; set; } = ""; // e.g. "avigilon:alarm" or "milestone:event:trigger"
public string ArgumentKey { get; set; } = ""; // e.g. "AlarmId", "Source", "Type"
public RuleFieldEditorKind EditorKind { get; set; } // Text, Numeric, Dropdown, CollectionHelper, Boolean, ReadOnly
// CollectionHelper Properties
public string? HelperCollectionItemId { get; set; } // Catalog item ID to browse (e.g. "avigilon:alarms")
public string? HelperDialogTitle { get; set; } // Picker window title
public string? HelperDisplayProperty { get; set; } // Display label property (e.g. "Name")
public string? HelperValueProperty { get; set; } // Property copied to field (e.g. "Id")
public string? HelperSubtitleProperty { get; set; } // Subtitle property (e.g. "State")
// Dropdown & Validation
public List<string>? AllowedValues { get; set; }
public string? ValidationPattern { get; set; }
public string? ValidationMessage { get; set; }
public bool TriggersPortRegeneration { get; set; }
}
9. Collection Field Helpers in Graph Nodes
A major feature of enterprise plugins (such as Avigilon and Milestone) is the ability to browse live catalog items directly from the Rule Editor when configuring node arguments (e.g. picking an alarm ID or camera ID into a "Write Value" or "Execute Command" graph node).
┌─────────────────────────────────────────────────────────────────────────────┐
│ GRAPH NODE COLLECTION FIELD HELPER │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Rule Node Property Panel │
│ Target: Write Value Action │
│ Item: avigilon:alarm │
│ │
│ [AlarmId: 1042 ] [ 🔍 ] ──┼──► Click Helper Button
└─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ CollectionItemPickerDialog │
│ Title: Select Avigilon Alarm │
├──────────────────────────────────────┤
│ 🔍 Search alarms... │
│ ──────────────────────────────────── │
│ ◉ Perimeter Alarm [ID: 1042] (Active)│
│ ○ Server Room Door [ID: 1088] (Ok) │
└──────────────────┬───────────────────┘
│ Select & Confirm
▼
Target field "AlarmId" populated with "1042"
Plugin Descriptor Examples
// Avigilon Alarm Picker Collection Helper
new RuleFieldDescriptor
{
TargetItemId = "avigilon:alarm",
ArgumentKey = "AlarmId",
EditorKind = RuleFieldEditorKind.CollectionHelper,
HelperCollectionItemId = "avigilon:alarms", // Catalog point returning alarm list
HelperDialogTitle = "Select Avigilon Alarm",
HelperDisplayProperty = "Name",
HelperValueProperty = "Id",
HelperSubtitleProperty = "State"
};
// Milestone Camera Picker Collection Helper
new RuleFieldDescriptor
{
TargetItemId = "milestone:event:trigger",
ArgumentKey = "Source",
EditorKind = RuleFieldEditorKind.CollectionHelper,
HelperCollectionItemId = "milestone:cameras",
HelperDialogTitle = "Select Camera",
HelperDisplayProperty = "Name",
HelperValueProperty = "Id",
HelperSubtitleProperty = "Description"
};
10. Custom Event Pickers & Dynamic Port Generation
For event-driven plugins (Socket.IO notifications, SIA alarms, HTTP webhooks), plugins expose structured event streams to Event Input rule nodes.
┌─────────────────────────────────────────────────────────────────────────────┐
│ EVENT PICKER & SCHEMA VALIDATION │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ EventPickerDialog │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. Select Event Source: [ Avigilon Enterprise Web Endpoint ▼ ] │
│ 2. Select Event Type: [ ALARMS / ALARM_TRIGGERED ▼ ] │
│ │
│ 3. Select Event Schema Fields to Expose as Ports: │
│ [x] AlarmId (String) │
│ [x] AlarmName (String) │
│ [x] State (String) │
│ [x] Timestamp (DateTime) │
│ [ ] RawPayload (Json) │
└────────────────────────────────────┬────────────────────────────────────┘
│ Confirm & Save
▼
Dynamically creates Output Ports on EventInput Node
Dynamic Output Port Generation
When event type and schema properties are confirmed in EventPickerDialog, RuleNodeViewModel executes RebuildEventPorts to generate matching output pins on the node:
private void RebuildEventPorts(List<EventSchemaField> selectedFields)
{
Dto.Ports.Clear();
// Trigger pulse output port
Dto.Ports.Add(CreatePort("trigger", "Trigger", GraphPortDirection.Output, GraphPortDataType.Bool, true));
// Dynamic schema property ports
foreach (var field in selectedFields)
{
var portType = GraphCatalogTypeMapper.ToPortType(field.DataType);
Dto.Ports.Add(CreatePort(field.Name, field.DisplayName, GraphPortDirection.Output, portType, true));
}
}
11. Dynamic Remote UI Delivery Pipeline
When configuring a plugin source in the UI, Uniflow streams the plugin's UI binary assembly over Named Pipe IPC and loads it into a light client session sandbox:
┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ Uniflow GUI │ │ Uniflow IPC Pipe│ │ UniflowService │
└──────┬───────┘ └────────┬────────┘ └──────────┬───────────┘
│ │ │
│ GetPluginUiBundle(SourceType) │ │
├──────────────────────────────────►│ │
│ │ GetPluginUiBundleRequest │
│ ├────────────────────────────────────►│
│ │ │ 1. Read assembly & deps
│ │ │ 2. Read PDBs
│ │ GetPluginUiBundleResponse │ 3. Build UI Bundle DTO
│ │◄────────────────────────────────────┤
│ Response (UiAssemblyBytes + Deps) │ │
│◄──────────────────────────────────┤ │
│ │
4. Load into ClientPluginSessionManager ALC │
5. PluginUiProvider resolves *EditorView & *EditorViewModel │
6. Render WPF UserControl in Settings modal │
Pipeline Steps
- GUI Request:
PluginUiProviderissuesIpcCommand.GetPluginUiBundlewith requestedSourceTypeID. - Service Packaging:
UniflowServicereads the plugin directory, extracts DLL bytes, PDBs, and third-party dependencies, returning aGetPluginUiBundleResponseenvelope. - Client ALC Load:
ClientPluginSessionManagercreates a light collectible ALC session (PluginSessionLoadContext) and loads assembly bytes. - Control Resolution:
PluginUiProviderreflects over the loaded assembly, instantiates the WPF*EditorViewUserControl and*EditorViewModel, bindingIIpcClientto host IPC.
12. Packaging and Deployment
Plugins are distributed as .zip archives containing the manifest, compiled assembly, icon, and optional third-party dependencies.
Uniflow.Plugin.MyCustomPlugin.zip
├── plugin.json
├── Uniflow.Plugin.MyCustomPlugin.dll
├── icon.png
└── System.IO.Ports.dll (Third-party dependency)
Service Path: Extract bundle to C:\ProgramData\Uniflow\Plugins\uniflow.plugin.mycustomplugin\.
Graphical UI: Launch Uniflow GUI, navigate to Settings -> Plugins, and click Upload Plugin (.zip). The GUI sends an InstallPlugin IPC request to UniflowService, which extracts the package, instantiates the adapter in an isolated ALC sandbox, and registers the source type.
13. Verification & Diagnostics Checklist
Follow this checklist to verify your custom plugin functions correctly across backend execution and frontend GUI rendering:
- ✔️ Service Assembly Load: Check
Logsfor[PluginManager] Loaded plugin 'My Custom Protocol' (v1.0.0). - ✔️ Remote UI Fetch: Confirm opening the source editor sends
GetPluginUiBundleand renders the custom WPF control cleanly. - ✔️ Source Editor Helper Windows: Open custom topic/catalog configuration dialogs (e.g. Avigilon Topics Dialog) and verify atomic topic selection and CSV output.
- ✔️ Graph Collection Helpers: Open Rules Editor, add a Write Value node targeting a plugin action, click helper icon (
🔍), and confirm the picker populates live items. - ✔️ Event Input Navigation: Add an Event Input node, open
EventPickerDialog, select exposed fields, and verify output ports generate correctly. - ✔️ Hot Unload Test: Uninstall or update the plugin while
UniflowServiceis running and verify memory is garbage collected without ALC leak warnings.