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
- MultiShock discovers plugin directories, packages, and loose DLLs.
- For version 2 manifests, the host validates metadata and app/SDK/platform constraints before loading the entry assembly.
- The loader constructs public, non-abstract
IPluginclasses with parameterless constructors. Keep constructors free of network connections and device output. - Enabled plugins receive
ConfigureServices(IServiceCollection)before the application's service provider is built. - The host orders initialization using
IPluginWithDependencies, validates required plugin IDs, and callsInitialize(IServiceProvider). - 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
| API | Use |
|---|---|
IPluginHost.CreateLogger(category) | Application-integrated logging |
ShowSuccessToast, ShowWarningToast, ShowInfoToast, ShowErrorToast | Nonblocking 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 |
IDeviceActions | Loaded shocker information, normal device actions, and serial tools |
IHostGlobalHotkeyService | Supported 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>():
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.