.NET 8.0 Collectible ALC Named Pipe IPC Remote WPF Hosting Free / PRO

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).

Service-Centric Plugin Architecture

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.

ARCHITECTURE DIAGRAM
┌─────────────────────────────────────────────────────────────────────────────┐
│                       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: UniflowService manages all plugin sandboxes inside collectible AssemblyLoadContext instances.
  • 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 ClientPluginSessionManager to load the delivered UI assembly into a light isolated ALC session, instantiating the *EditorView and *EditorViewModel dynamically.
  • 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.

XML - Uniflow.Plugin.MyCustomPlugin.csproj
<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>
Critical Reference Setting

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.

JSON - plugin.json
{
  "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.

Resource Disposal Best Practices

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.

C# - MyCustomSourceAdapter.cs
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).

C# - MyCustomSourceEditorViewModel.cs
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}";
    }
}
XAML - MyCustomSourceEditorView.xaml
<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
┌─────────────────────────────────────────────────────────────────────────────┐
│                      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:

C# - AvigilonTopicsConfigurationViewModel.cs
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.

C# - MyCustomPluginDefinition.cs
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.

C# - PluginRuleIntegration & RuleFieldDescriptor Schemas
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
┌─────────────────────────────────────────────────────────────────────────────┐
│                    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

C# - Avigilon & Milestone Collection Helpers
// 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
┌─────────────────────────────────────────────────────────────────────────────┐
│                       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:

C# - RebuildEventPorts Method
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:

REMOTE UI DELIVERY WORKFLOW
┌──────────────┐                  ┌─────────────────┐                 ┌──────────────────────┐
│  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

  1. GUI Request: PluginUiProvider issues IpcCommand.GetPluginUiBundle with requested SourceType ID.
  2. Service Packaging: UniflowService reads the plugin directory, extracts DLL bytes, PDBs, and third-party dependencies, returning a GetPluginUiBundleResponse envelope.
  3. Client ALC Load: ClientPluginSessionManager creates a light collectible ALC session (PluginSessionLoadContext) and loads assembly bytes.
  4. Control Resolution: PluginUiProvider reflects over the loaded assembly, instantiates the WPF *EditorView UserControl and *EditorViewModel, binding IIpcClient to host IPC.

12. Packaging and Deployment

Plugins are distributed as .zip archives containing the manifest, compiled assembly, icon, and optional third-party dependencies.

ZIP STRUCTURE
Uniflow.Plugin.MyCustomPlugin.zip
  ├── plugin.json
  ├── Uniflow.Plugin.MyCustomPlugin.dll
  ├── icon.png
  └── System.IO.Ports.dll (Third-party dependency)
Deployment Paths & Installation

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 Logs for [PluginManager] Loaded plugin 'My Custom Protocol' (v1.0.0).
  • ✔️ Remote UI Fetch: Confirm opening the source editor sends GetPluginUiBundle and 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 UniflowService is running and verify memory is garbage collected without ALC leak warnings.