Writing plugins
A MultiShock plugin is a .NET Razor class library that MultiShock loads at startup. Plugins can add pages to the sidebar, a settings screen, and flow nodes, and can send commands to the user's shockers. This guide assumes you know C# and Blazor, and covers version 1.15.0 of the plugin SDK in this source tree. Build against an SDK supported by your target MultiShock release; installing a newer package in your plugin does not upgrade the host.
For more detail, see lifecycle and host services, pages and shared UI, developing flow nodes, and packaging and testing.
Quick start
The easiest way to start is from the official plugins repository, bwmp/Multishock.Plugins:
- Fork the repository and clone it.
- Run
.\scripts\new-plugin.ps1. It asks for a name, ID, and description, and creates a project with a plugin class, a page, a settings screen, and an example flow node. - Build the project. It copies the plugin into MultiShock's plugin folder.
- Restart MultiShock, then switch on your plugin's page under Manage plugins → Configure → Sidebar pages.
Check the generated project's SDK package reference and manifest before building: the template may pin an older SDK. Use a dedicated development install directory and remove duplicate copies with the same plugin ID.
The rest of this page explains the pieces, whether you use the template or start from scratch.
Project setup
Create a Razor class library that targets .NET 10 and references the SDK
package, PiShock.MultiShock.PluginSdk:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0" />
<PackageReference Include="PiShock.MultiShock.PluginSdk" Version="1.15.0" />
</ItemGroup>
</Project>
MultiShock always uses its own copy of the SDK, so don't ship
MultiShock.PluginSdk.dll with your plugin.
Install the .NET 10 SDK
to build this project. An external Razor library does not require the MAUI
workload. PiShock.MultiShock.PluginSdk is the package ID;
MultiShock.PluginSdk is the namespace and assembly name.
For a project created by hand, put the project file, plugin class, and node
class below in one MyPlugin directory. Add the Razor page when you want a UI,
and add these shared component imports:
@using Microsoft.AspNetCore.Components
@using MultiShock.PluginSdk
@using MultiShock.PluginSdk.Components
From that directory, build with:
dotnet build .\MyPlugin.csproj -c Release
This produces bin\Release\net10.0\MyPlugin.dll. A project without a custom
copy target does not install itself; follow local installation
after building.
The manifest
Put a plugin.json file next to your DLL. It tells MultiShock what the plugin
is and whether it can run:
{
"manifestVersion": 2,
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "1.0.0",
"entryPoint": "MyPlugin.dll",
"description": "Does something useful.",
"sdkVersion": "1.15.0",
"platforms": ["win-x64"]
}
| Field | Required | Meaning |
|---|---|---|
manifestVersion | yes | Always 2. |
id | yes | A unique ID in lowercase, such as com.example.myplugin. |
name | yes | The name users see. |
version | yes | The plugin's version, such as 1.0.0. |
entryPoint | yes | The DLL that contains your plugin class. |
description | A one-line description. | |
authors | A list of author names. | |
sourceUrl | Where to find the source or downloads. | |
minAppVersion | The oldest MultiShock version it runs on, such as 4.8.0. | |
sdkVersion | The SDK version you built against. | |
dependencies | Other plugins it needs, as { "id", "range", "optional" }. | |
tags | Words to help people find it. | |
platforms | Runtime identifiers it runs on, such as win-x64. Leave it out to run anywhere. |
MultiShock skips a plugin when the app is older than minAppVersion, when
sdkVersion has a newer major version than MultiShock's SDK, or when the
current platform isn't listed. Keep id and version the same as your plugin
class's Id and Version; update checks compare against those.
The SDK check only compares major versions. It does not guarantee that an older
host has APIs introduced in a newer SDK minor release. Test the oldest app you
support and set minAppVersion accordingly. See the
manifest guide for dependency limits.
The plugin class
Every plugin has one public class that implements IPlugin and has a
parameterless constructor:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MultiShock.PluginSdk;
using MultiShock.PluginSdk.Flow;
namespace MyPlugin;
public class MyPlugin : IPlugin, IPluginRouteProvider, IFlowNodeProvider
{
public const string PluginId = "com.example.myplugin";
public string Id => PluginId;
public string Name => "My Plugin";
public string Version => "1.0.0";
public string Description => "Does something useful.";
internal static ILogger? Logger { get; private set; }
public void ConfigureServices(IServiceCollection services)
{
// Register your own services here.
}
public void Initialize(IServiceProvider services)
{
var host = services.GetRequiredService<IPluginHost>();
Logger = host.CreateLogger("MyPlugin.Plugin");
Logger.LogInformation("My Plugin started");
}
public IEnumerable<NavigationItem> GetNavigationItems() =>
[
new NavigationItem { Text = "My Plugin", Href = "/myplugin", Icon = "puzzle" },
];
public IEnumerable<IFlowNode> GetNodeTypes() => [new BuzzNode()];
}
MultiShock calls the methods in this order:
ConfigureServices, while the app is starting, to add your services to the app's service container.Initialize, once the app has started, with the service provider. Start background work here.GetNodeTypes, once, when the flow system loads.
There is no shutdown method; when MultiShock closes, your plugin closes with it.
Services you can use
Resolve these from the service provider in Initialize, from @inject in your
pages, or from context.GetService<T>() in flow nodes:
IPluginHost:CreateLogger(category)writes to MultiShock's logs. Include "Plugin" in the category name, as above, so the messages go to the plugin log file.ShowSuccessToast,ShowWarningToast,ShowInfoToast, andShowErrorToastshow notifications.GetPluginDataPath(pluginId)returns a folder for your plugin's files. Save your settings there:GetSettingandSetSettingonly keep values until MultiShock closes.GetPlugin<T>()andGetPluginService<T>()reach other plugins.
IDeviceActions:GetLoadedShockers()lists the shockers in use.PerformAction(intensity, durationSeconds, CommandType.Vibrate, ...)sends a command. It targets every loaded shocker unless you passdeviceIdsorshockerIds, andshockerOption: "random"picks one of them.
Commands sent with PerformAction go through the user's
safety controls: Allow shocks, Power limit, cooldowns, and
Stop everything. IDeviceActions also has raw serial methods for USB hubs;
those bypass the safety controls, so use PerformAction for anything that
reaches a person.
Pages
Add pages as Razor components. Their route must start with /plugins/, then
your plugin ID with dots replaced by dashes:
@page "/plugins/com-example-myplugin/myplugin"
<h1 class="page-title">My Plugin</h1>
To list a page in the sidebar, implement IPluginRouteProvider as in the plugin
class above. Href is the part after your plugin's prefix, and Icon is the
name of a Lucide icon. Users have to switch new
pages to Shown under Manage plugins → Configure → Sidebar pages
before they appear, so mention that in your plugin's instructions.
Styling
Pages can use the host's bundled Tailwind utilities and MultiShock's theme:
- Colors such as
bg-app-surface,text-app-muted,border-app-border, andtext-app-primary, which follow the user's theme. - CSS variables for your own styles, such as
var(--ms-surface),var(--ms-text), andvar(--ms-primary). - Components from
MultiShock.PluginSdk.Components, includingPageHeader,Card,Button,Toggle,TextInput,Select,Slider,Alert, andShockerSelect.
For CSS of your own, implement IPluginWithStyles and return it from
GetStylesheet().
Runtime plugin source is not scanned by the host's Tailwind build. Arbitrary values and custom classes need CSS you supply. See styling and shared components.
Settings screen
Implement IConfigurablePlugin to add a settings screen under Manage
plugins → Configure. GetConfigurationComponentType() returns your Razor
component, which receives a PluginId parameter. MultiShock doesn't currently
call OnConfigurationChanged, so have your component save settings itself, to
a file under GetPluginDataPath.
Flow nodes
Implement IFlowNodeProvider and return one instance of each node type from
GetNodeTypes(). This node vibrates every loaded shocker:
using MultiShock.PluginSdk;
using MultiShock.PluginSdk.Flow;
namespace MyPlugin;
public sealed class BuzzNode : IFlowProcessNode
{
public string TypeId => "myplugin.buzz";
public string DisplayName => "Buzz";
public string Category => FlowNodeCategory.Action;
public string? Description => "Vibrates every loaded shocker for one second.";
public string Icon => "vibrate";
public string? Color => null;
public IReadOnlyList<FlowPort> InputPorts { get; } =
[
FlowPort.FlowIn(),
FlowPort.Number("intensity", "Intensity"),
];
public IReadOnlyList<FlowPort> OutputPorts { get; } = [FlowPort.FlowOut()];
public IReadOnlyDictionary<string, FlowProperty> Properties { get; } =
new Dictionary<string, FlowProperty>
{
["intensity"] = FlowProperty.Int("Intensity", 20, min: 1, max: 100),
};
public Task<FlowNodeResult> ExecuteAsync(
IFlowNodeInstance instance,
FlowExecutionContext context,
CancellationToken cancellationToken)
{
var actions = context.GetService<IDeviceActions>();
if (actions is null)
return Task.FromResult(FlowNodeResult.Fail("Device actions not available"));
// Unconnected inputs are missing from Inputs, so fall back to the property.
var intensity = context.Inputs.ContainsKey("intensity")
? context.GetInput<int>("intensity")
: instance.GetConfig("intensity", 20);
actions.PerformAction(Math.Clamp(intensity, 1, 100), 1.0, CommandType.Vibrate);
return Task.FromResult(FlowNodeResult.ActivatePort("done"));
}
public IFlowNodeInstance CreateInstance(string instanceId, Dictionary<string, object?> config)
=> new FlowNodeInstance(instanceId, this, config);
}
Ports and properties
FlowPort.FlowIn()andFlowPort.FlowOut()create the usual Execute and Done pins.FlowPort.Boolean,Number,String,Any,Array, andObjectcreate data pins. See pin types for what can connect to what.FlowProperty.String,Int,Double,Bool, andSelectcreate settings in the property panel.IntandDoubleshow a slider when you give both a minimum and a maximum. SetRequired = trueto have the editor flag a missing value.- New nodes start with no saved settings, so
GetConfigreturns the property's default. That only works when the default's type matches the type you ask for, so read anIntproperty withGetConfig<int>, and so on. context.Inputsonly holds connected inputs. Fall back to a property when an input isn't connected, as above.
Results
Return one of these from ExecuteAsync:
| Result | What happens |
|---|---|
FlowNodeResult.ActivatePort("done", outputs) | Sets the data outputs and continues from that pin. Use it to pick a branch. |
FlowNodeResult.ActivatePorts([...], outputs) | Continues from several pins, in order. |
FlowNodeResult.Success(outputs) | Sets the data outputs and continues from Done. |
FlowNodeResult.Stop() | Ends this branch quietly. |
FlowNodeResult.Fail("message") | Ends this branch and shows the red error badge on the node, with the message. |
Add .WithDisplayValue("text") to any result to show a value under the node's
pins.
Trigger nodes
A trigger implements IFlowTriggerNode instead:
StartAsync(instance, services, cancellationToken)runs when a flow using the node starts. Begin listening for your event here.StopAsync(instance)runs when that flow stops.- Raise the
Triggeredevent with the sameinstanceand a dictionary of output values to start the flow.
A trigger's Flow output must have the ID triggered, for example
new FlowPort { Id = "triggered", Name = "Triggered", Type = FlowPortType.Flow }.
MultiShock uses one node object for every flow, so keep per-flow state keyed by
instance, or in instance.State.
Naming
- MultiShock stores your node types as
yourPluginId:typeId, so a shortTypeIdsuch asmyplugin.buzzis enough. - Use a
FlowNodeCategoryconstant (Trigger,Logic,Action, and so on) to put your node in a standard group and give it a matching header color. Any other category name gets its own group, listed after the standard ones.
Build and test
The template's project copies your DLL to
%LocalAppData%\PiShock\MultiShock\Plugins after each build. Without it, copy
the DLL there yourself, then restart MultiShock to load it.
To reload your plugin without restarting, use development mode:
- Create an empty file next to your DLL with
.devadded to its name, such asMyPlugin.dll.dev. - Restart MultiShock once.
- After each build, open Manage plugins → Developer and click Reload on your plugin.
Reloading picks up changes to pages and services. Changes to flow nodes still need a restart.
If your plugin doesn't load, open Settings → Advanced → Debug & diagnostics → Plugins and check Recent Plugin Errors. Your plugin's log messages are in the plugin log, next to the main log. See troubleshooting.
Share your plugin
A .msplugin file is a zip archive with plugin.json at the top, next to your
DLL and its dependencies. Users install it by copying it into their plugin
folder; see installing plugins.
To get a plugin into the Browse list, open a pull request to bwmp/Multishock.Plugins. Plugins there are built, packaged, and published to the catalog automatically when a new version is released.