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.

HomePage.razor
@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:

MemberHost 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:

PurposeComponents
Page structurePageHeader, Card, SettingRow, Tabs, EmptyState
Actions and feedbackButton, Alert, Badge, StatTile, LucideIcon
FormsTextInput, TextArea, NumberInput, Select, Slider, Toggle, Checkbox, ColorPicker
DialogsModal, ConfirmDialog
Device selectionShockerSelect

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:

WidgetContent
VrWatchTextText with a configurable line limit, up to four lines
VrWatchStatsUp to four labeled values
VrWatchMeterA labeled fraction from 0 to 1, with optional value text
VrWatchButtonsButtons arranged up to three per row
VrWatchToggleA labeled boolean control
VrWatchSliderA 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.

Contributors

Thank you to everyone who has helped improve this documentation.