Lifecycle and host services

The public SDK is the boundary between your plugin and MultiShock. Reference PiShock.MultiShock.PluginSdk, import MultiShock.PluginSdk, and avoid references to the application's internal assemblies. Start with the complete plugin class in writing plugins.

Startup sequence

  1. MultiShock discovers plugin directories, packages, and loose DLLs.
  2. For version 2 manifests, the host validates metadata and app/SDK/platform constraints before loading the entry assembly.
  3. The loader constructs public, non-abstract IPlugin classes with parameterless constructors. Keep constructors free of network connections and device output.
  4. Enabled plugins receive ConfigureServices(IServiceCollection) before the application's service provider is built.
  5. The host orders initialization using IPluginWithDependencies, validates required plugin IDs, and calls Initialize(IServiceProvider).
  6. Successfully initialized plugins contribute navigation, default settings, styles, and other supported extensions. Plugin flow nodes are registered separately by the flow subsystem.

Use ConfigureServices to register services, not resolve them or start work. Use Initialize to resolve host services and arrange initialization that does not block the UI. The interface is synchronous: own and observe any asynchronous startup task you create, handle its failures, and provide a cancellation path. An initialization exception can leave a plugin discovered but unusable; check Debug & diagnostics → Plugins for the actual outcome.

Services supplied by the host

APIUse
IPluginHost.CreateLogger(category)Application-integrated logging
ShowSuccessToast, ShowWarningToast, ShowInfoToast, ShowErrorToastNonblocking user feedback
ShowNotificationAsync(message, title)A host notification dialog
AppDataPath, GetPluginDataPath(pluginId)Resolved per-user data locations
PickFileAsync(title, extensions)File picker; cancellation returns null
StartGuidedSetup(), StartPluginGuidedSetup(pluginId, setupId)Start a host or plugin tour
IDeviceActionsLoaded shocker information, normal device actions, and serial tools
IHostGlobalHotkeyServiceSupported desktop global hotkey registration

Resolve required services with GetRequiredService<T>(); use GetService<T>() when absence is a supported state. For a Razor component, use @inject for host services. For your own services during development reload, use PluginServiceLocator.GetRequiredService<T>(pluginId); see reload.

Logging

var host = services.GetRequiredService<IPluginHost>();
var logger = host.CreateLogger("MyPlugin.Plugin");
logger.LogInformation("Plugin initialized");

Include Plugin in the category so the host routes messages into the separate plugin log when that option is enabled. With separate plugin logs disabled, messages go to the main log. Record connection transitions, actionable failures, and cleanup failures without logging tokens, passwords, or full sensitive payloads. Find files under Settings → Logging → Open folder.

Persistent settings

IPluginHost.GetSetting<T>() and SetSetting<T>() use an in-memory dictionary. They are not persisted across app restarts. Namespace those keys by plugin ID if you use them for session state.

Store durable settings under host.GetPluginDataPath(MyPlugin.PluginId), normally PluginData/<plugin-id>/ inside the app data folder. The host creates the directory. Do not write beside the loaded assembly: it may be a temporary shadow copy that disappears later.

This small store can be registered in your plugin's ConfigureServices with services.AddSingleton<PluginOptionsStore>():

PluginOptionsStore.cs
using System.Text.Json;
using MultiShock.PluginSdk;
 
namespace MyPlugin;
 
public sealed record PluginOptions(string Message = "Hello from My Plugin");
 
public sealed class PluginOptionsStore
{
    private readonly string _path;
    private readonly SemaphoreSlim _saveGate = new(1, 1);
 
    public PluginOptionsStore(IPluginHost host)
    {
        _path = Path.Combine(host.GetPluginDataPath(MyPlugin.PluginId), "settings.json");
    }
 
    public async Task<PluginOptions> LoadAsync(CancellationToken cancellationToken = default)
    {
        if (!File.Exists(_path))
            return new PluginOptions();
 
        var json = await File.ReadAllTextAsync(_path, cancellationToken);
        return JsonSerializer.Deserialize<PluginOptions>(json)
            ?? throw new JsonException("Plugin settings must contain an options object.");
    }
 
    public async Task SaveAsync(PluginOptions options, CancellationToken cancellationToken = default)
    {
        await _saveGate.WaitAsync(cancellationToken);
        try
        {
            var temporaryPath = _path + ".tmp";
            var json = JsonSerializer.Serialize(options);
            await File.WriteAllTextAsync(temporaryPath, json, cancellationToken);
            File.Move(temporaryPath, _path, overwrite: true);
        }
        finally
        {
            _saveGate.Release();
        }
    }
}

Handle IOException, UnauthorizedAccessException, and invalid JSON at the settings UI or startup boundary where you can explain the failure. Keep the existing file for recovery; do not silently replace malformed settings with defaults. Serialize writes through one store instance. Do not log settings contents when reporting an error.

Host configuration export does not collect these plugin-owned files. Tell users which files need backing up and handle missing files on a new installation.

Device actions

IDeviceActions.GetLoadedShockers() returns a snapshot with DeviceId, ShockerId, and Name. Those IDs identify currently loaded targets; an enabled shocker is not proof that the hardware is reachable.

For an explicit, low-intensity vibration of one chosen loaded shocker:

cancellationToken.ThrowIfCancellationRequested();
var actions = context.GetRequiredService<IDeviceActions>();
if (!actions.IsShockerLoaded(deviceId, shockerId))
    return FlowNodeResult.Fail("The selected shocker is not loaded.");
 
actions.PerformAction(
    intensity: 10,
    durationSeconds: 0.5,
    command: CommandType.Vibrate,
    deviceIds: new[] { deviceId },
    shockerIds: new[] { shockerId });
 
return FlowNodeResult.Success();

This is an execution-method fragment with context, deviceId, shockerId, and cancellationToken supplied by your node. Import both MultiShock.PluginSdk and MultiShock.PluginSdk.Flow.

PerformAction returns void: successful submission is not a delivery receipt. With no target filters it selects all loaded shockers. shockerOption supports selection with all or random; use explicit target selection when an action must reach only one device.

Normal actions pass through the user's power scaling, shock permissions, cooldowns, and queues. Validate finite durations and intensity ranges in your own plugin before submitting them. Raw serial methods such as SendSerialCommand bypass this normal action path; reserve them for intentional device-management work, not ordinary output to shockers.

Stop everything stops flows and queued output but does not disable a plugin or permanently block new submissions. Give plugins that generate output on their own a clear pause/stop control and cancel their producers when stopped.

Other plugins and dependencies

Implement IPluginWithDependencies to declare RequiredPlugins and OptionalPlugins by stable plugin ID. Required IDs are checked before your plugin's initialization, and dependency ordering also considers optional IDs that are present. Avoid dependency cycles.

Use IPluginHost.GetPlugin, IsPluginLoaded, GetLoadedPlugins, GetPluginService<T>, or GetRequiredPluginService<T> to locate integrations. Availability flags do not guarantee that another plugin connected to its external service or initialized successfully. Optional integrations must tolerate absence. Typed cross-plugin calls also require a compatible shared contract and assembly identity; test them with development reload as well as a fresh startup.

Manifest dependencies describe package requirements. The current runtime dependency resolver uses the interface's plugin IDs, not manifest version ranges. Do not rely on a manifest range to enforce initialization order or install dependencies automatically. See packaging.

Hotkeys and cleanup

IHostGlobalHotkeyService.IsSupported and UnsupportedReason describe the current platform's support. TryRegister returns a registration handle or an error; show registration failures to the user and dispose the handle when the feature is disabled or unloaded. A global hotkey registration is distinct from the app's focused-window shortcuts.

For development reload, implement IReloadablePlugin or derive from ReloadablePluginBase. The host calls OnUnloading() during a development unload. Cancel owned tasks, unsubscribe events, release hotkeys and sockets, and dispose JavaScript resources. PluginServices exposes your plugin's service provider. The base class creates a plugin-owned provider and registers it with PluginServiceLocator; cleanup of your own resources remains your responsibility.

Do not assume unloading or normal app exit will always complete asynchronous work. Save durable settings when the user changes them. Changes to flow node types still require a full restart; see development reload.

Contributors

Thank you to everyone who has helped improve this documentation.