Pages and shared UI
Plugins use Blazor Razor components rendered inside the host. This guide extends
the MyPlugin example from writing plugins; it does not
require changes to the main app's router.
Routes and navigation
For plugin ID com.example.myplugin, dots become dashes in the URL prefix:
/plugins/com-example-myplugin/. The ID used in manifests, settings, and service
lookup remains com.example.myplugin.
Return a plugin-relative route from IPluginRouteProvider.GetNavigationItems():
public IEnumerable<NavigationItem> GetNavigationItems() =>
[
new NavigationItem
{
Text = "My Plugin",
Href = "/myplugin",
Icon = "plug",
Order = 50
}.WithId("main")
];
The Razor page must declare the full prefixed route. The host prefixes navigation
items, not your @page directive. Keep navigation IDs stable so users' sidebar
visibility preferences continue to match.
@page "/plugins/com-example-myplugin/myplugin"
@using MultiShock.PluginSdk
@using MultiShock.PluginSdk.Components
@inject IPluginHost Host
<PageHeader Title="My Plugin" Description="Check that the plugin page is loaded." />
<div class="max-w-3xl space-y-4">
<p class="text-app-muted">The plugin page is ready.</p>
<Button Text="Show test message" Icon="bell" OnClick="ShowMessage" />
</div>
@code {
private void ShowMessage()
{
Host.ShowInfoToast("My Plugin", "The page can reach host services.");
}
}
After installing and restarting, turn the page to Shown under Manage plugins → Configure → Sidebar pages. Hiding a sidebar item does not disable the plugin or remove the route. Do not use page visibility as an authorization mechanism.
Resolve plugin services
Host services such as IPluginHost can be injected directly. To reach a
plugin-specific service registered in ConfigureServices, resolve it from the
SDK locator during component initialization:
private PluginOptionsStore _store = default!;
protected override void OnInitialized()
{
_store = PluginServiceLocator.GetRequiredService<PluginOptionsStore>(MyPlugin.PluginId);
}
This fragment uses the store from persistent settings.
Add the appropriate namespace import for your component. Re-resolve on each
new component instance; retaining old static service references across reloads
can keep an old plugin assembly alive. Marshal background event updates through
InvokeAsync(StateHasChanged) and unsubscribe component listeners on disposal.
Configuration screen
Implement IConfigurablePlugin on your plugin entry class:
| Member | Host behavior |
|---|---|
GetConfigurationComponentType() | Returns the Razor component displayed under Plugin settings |
GetDefaultSettings() | Supplies missing keys in the host-managed plugin settings dictionary |
OnConfigurationChanged(settings) | SDK callback available to implement; the current host UI does not invoke it |
The configuration component must accept the parameter passed by the host:
[Parameter]
public string? PluginId { get; set; }
The host provides the plugin ID, not a working automatic settings form. Your
component must load, validate, and save its actual settings and notify its
services. Use a plugin-owned store under GetPluginDataPath; do not expect
editing a form or returning defaults to write that file automatically.
Show loading, save failure, validation, and success feedback where they occur. For connection settings, separate Save from a deliberate Connect action when connecting has side effects. Never put credentials into flow display values, URLs, or log messages.
Styling and shared components
Import MultiShock.PluginSdk.Components in a page or _Imports.razor. The SDK
includes these components:
| Purpose | Components |
|---|---|
| Page structure | PageHeader, Card, SettingRow, Tabs, EmptyState |
| Actions and feedback | Button, Alert, Badge, StatTile, LucideIcon |
| Forms | TextInput, TextArea, NumberInput, Select, Slider, Toggle, Checkbox, ColorPicker |
| Dialogs | Modal, ConfirmDialog |
| Device selection | ShockerSelect |
Use Value/ValueChanged or @bind-Value for input components. For example,
TextInput takes a nullable string; NumberInput takes a nullable double.
Button supports Text, Icon, Variant, Disabled, Loading, and OnClick.
Give icon-only buttons an AriaLabel and keep keyboard focus visible.
Prefer semantic host utilities such as bg-app-surface, text-app-text,
text-app-muted, border-app-border, and text-app-primary. These follow
light/dark appearance and custom themes. CSS can use var(--ms-surface),
var(--ms-text), var(--ms-muted), and var(--ms-primary).
The host supplies a bundle of Tailwind utilities, but it does not compile CSS
from newly loaded plugin source. Arbitrary values, custom variants, and classes
outside that bundle need plugin-supplied styles. Implement IPluginWithStyles:
public string GetStylesheet() => """
.myplugin-status {
color: var(--ms-text);
border-inline-start: 2px solid var(--ms-primary);
padding-inline-start: 0.75rem;
}
""";
public string? GetStylesheetId() => "com-example-myplugin-styles";
Return CSS text, not a file path. Scope selectors to your plugin so they do not
restyle host controls or other plugins. Do not assume a Razor .razor.css bundle,
an arbitrary _content/ URL, or a loose asset will be served merely because its
file was copied into the plugin directory; verify asset loading in the installed
package. Use SDK components and host-injected CSS for the basic page.
JavaScript interop
The host registers SDK interfaces from MultiShock.PluginSdk.JSInterop as scoped
services: IStyleInterop, IDomInterop, IDropdownInterop,
IDropdownPortalInterop, IDateTimeInterop, and IColorPickerInterop.
Prefer the shared components for ordinary controls; use these services when
custom rendering needs the corresponding behavior.
Resolve interop in the component's rendering scope and invoke DOM-dependent
operations after the component has rendered. Do not capture a scoped IJSRuntime
or interop service in a long-lived singleton. Dispose resources you create and
remove event listeners when the component or plugin unloads. The host's
IDomCleanupService assists reload cleanup; it does not replace your ownership
of custom JavaScript resources.
Guided setup tours
Implement IPluginGuidedSetupProvider on the entry class. Routes can be relative
to your plugin; the host normalizes them using the same dot-to-dash ID rule.
public IEnumerable<GuidedSetupDefinition> GetGuidedSetups() =>
[
new GuidedSetupDefinition(
Id: "quickstart",
Title: "My Plugin setup",
StartRoute: "/myplugin",
Steps:
[
new GuidedSetupStep(
Id: "message",
Title: "Test the page",
Description: "Use this button to check host notifications.",
TargetSelector: "[data-guided-setup='myplugin-message']",
NextMode: GuidedSetupNextMode.Manual)
])
];
Wrap the example page's test button in an element with
data-guided-setup="myplugin-message". Use a stable, unique selector that is
visible when the step runs. Manual advances with the tour control;
WaitForRoute waits for ExpectedRoute; WaitForLogin waits for host PiShock
sign-in, not an arbitrary plugin account connection.
The host exposes a Setup action for plugins with tours. A page can also call
Host.StartPluginGuidedSetup(MyPlugin.PluginId, "quickstart"); it returns false
if no matching tour is available. Test the tour after a fresh install, with
hidden sidebar pages, and after cancellation.
Optional watch content contract
SDK 1.15.0 also defines IVrWatchProvider in MultiShock.PluginSdk.VrWatch.
This is an optional extension contract: a compatible watch renderer must be
present to consume it. Implementing it alone does not create a desktop page or
install a renderer.
Implement GetWatchContent() to return VrWatchContent containing Cards and
Pages. Each card or page contains widgets that the renderer lays out in its
own style:
| Widget | Content |
|---|---|
VrWatchText | Text with a configurable line limit, up to four lines |
VrWatchStats | Up to four labeled values |
VrWatchMeter | A labeled fraction from 0 to 1, with optional value text |
VrWatchButtons | Buttons arranged up to three per row |
VrWatchToggle | A labeled boolean control |
VrWatchSlider | A value with minimum, maximum, step, and optional unit |
OnWatchInputAsync(input, cancellationToken) receives VrWatchButtonPressed,
VrWatchToggleChanged, or VrWatchSliderChanged, identified by ControlId.
The optional returned string is a short feedback message. A slider reports a
changed value on release rather than on every drag movement.
Keep card/page IDs stable and control IDs unique across all of your watch content. Return quickly from cached state: content can be requested from any thread, about twice a second while visible and after inputs. Inputs are serialized per plugin on the thread pool, but can overlap other plugin work; synchronize shared state accordingly and honor cancellation. Keep cards short and place longer content on a page.