Serving a view
Implement MacroDeck.Sdk.Ui.IUiProvider to serve a view; Macro Deck owns the session it renders in.
Example
Section titled “Example”A deck widget that counts presses, shared by every device showing it:
using MacroDeck.Sdk.Ui;using MacroDeck.Ui.Components;using MacroDeck.Ui.Dsl;using MacroDeck.Ui.Model.Events;using MacroDeck.Ui.Model.Nodes;using MacroDeck.Ui.Model.Patches;using MacroDeck.Ui.Model.Surfaces;using MacroDeck.Ui.Runtime;
public sealed class CounterUiProvider : IUiProvider{ private readonly UiState<int> _count = new(0);
public IReadOnlyList<UiSurfaceDeclaration> Surfaces { get; } = [new() { Kind = UiSurfaceKinds.Widget, SessionMode = UiSessionModes.Shared }];
public Task<IUiSession?> CreateSessionAsync(UiSessionRequest request, CancellationToken cancellationToken) { if (request.Surface.Kind != UiSurfaceKinds.Widget) { return Task.FromResult<IUiSession?>(null); }
var root = new UiButton { Key = "counter", Events = [UiEventHandler.On(UiComponentEvents.Press, () => _count.Value++)], Children = [new UiTextRun { Key = "value", Text = UiText.From(() => _count.Value.ToString()), Size = 0.4 }], };
return Task.FromResult<IUiSession?>(new ViewSession(new UiView(request.Surface, root))); }}
public sealed class ViewSession : IUiSession{ private readonly UiView _view;
public ViewSession(UiView view) { _view = view; _view.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty); _view.HandlerFaulted += (_, fault) => Faulted?.Invoke(this, new UiSessionFaultedEventArgs(fault.Exception.Message, fault.Exception)); }
public event EventHandler? Changed;
public event EventHandler<UiSessionFaultedEventArgs>? Faulted;
public UiTree BuildTree() => _view.Tree;
public IReadOnlyList<UiPatch> DrainPatches() => _view.DrainPatches();
public void Dispatch(UiEvent uiEvent) => _view.Dispatch(uiEvent);
public ValueTask DisposeAsync() { _view.Dispose(); return ValueTask.CompletedTask; }}ViewSession is the whole adapter between a MacroDeck.Ui UiView and IUiSession; the other pages
in this section reuse it. IUiSession itself speaks only MacroDeck.Ui.Model terms, so a provider can
serve a tree without the DSL.
Disposing the view is what lets a closed session go. _count belongs to the provider and outlives every
session, and a state keeps each view that reads it alive and flushes into it on every write. A session that
skips _view.Dispose() stays in memory for as long as the plugin runs, and so does every patch queued for
it. See Disposing.
Declaring surfaces
Section titled “Declaring surfaces”public IReadOnlyList<UiSurfaceDeclaration> Surfaces { get; } =[ new() { Kind = UiSurfaceKinds.Widget, SessionMode = UiSessionModes.Shared }, new() { Kind = UiSurfaceKinds.Config, SessionMode = UiSessionModes.Exclusive },];Macro Deck reads Surfaces before anything is initialized, so it must be side-effect free and must not
depend on a live connection. Declaring a surface does not commit you to every session for it: return
null from CreateSessionAsync to decline one. The kind vocabulary is open (see
Views and surfaces), so declining a kind you do not recognise is correct, not an error.
Answering the client that acted
Section titled “Answering the client that acted”Macro Deck calls Dispatch(UiEvent, IUiEventOrigin) for every client event. The default forwards to
Dispatch(UiEvent), so a session that does not care who acted implements only that. The origin names the
client (ClientId) and acts on it:
| Member | What the client sees |
|---|---|
ReportFailureAsync(message) |
A failure, shown the way a failed action is shown, for example as a toast. |
ShowModalAsync(modal) |
One of your provider’s dialog views, opened as a modal; returns once it is open. |
ShowModalAsync<T>(modal) |
The same, waiting for the user’s answer. |
OpenUrlAsync(url) |
An http or https page opened in the browser of the device that pressed, for example the phone running the web client. Never the computer Macro Deck runs on, unless that is where the press came from. |
An origin stays usable after your handler returned, so work that finishes later - a request to a device or
a server - can still report its failure on the client that pressed. It acts only while that client is still
attached to the session: once it closed the view or disconnected, the calls answer false or a cancellation
and never throw for that. An event Macro Deck raised itself has no client, and its origin reaches nobody.
With the DSL, keep the origin in a field before handing the event to the view. Dispatch is never called
concurrently for one session, so the handler that runs during _view.Dispatch reads the right one:
public sealed class ViewSession : IUiSession{ private IUiEventOrigin? _origin;
// Other members as in the example above.
public void Dispatch(UiEvent uiEvent) => _view.Dispatch(uiEvent);
public void Dispatch(UiEvent uiEvent, IUiEventOrigin origin) { _origin = origin; _view.Dispatch(uiEvent); }
private async Task ToggleAsync() { var origin = _origin; if (!await _client.TryToggleAsync()) { await origin!.ReportFailureAsync(Strings.Widget.ToggleFailed()); } }}Capture the origin into a local before the first await, as ToggleAsync does: the field holds the
origin of the latest event, which may be a different client’s by the time the work resumes.
Opening a link on the client that pressed
Section titled “Opening a link on the client that pressed”OpenUrlAsync is narrower than the other members, because it puts a page in front of someone:
- It accepts only an absolute
httporhttpsURL and throwsArgumentExceptionfor anything else, such asjavascript:,file:or a relative path. - It reaches only the connection the event came from, not every tab or device that shares its client id.
- That connection must have told Macro Deck it can open links. The web client and the desktop UI do; hardware decks, older clients and clients that cannot open a browser do not.
- It works only for a short time after the event, about 10 seconds, so answer the press with it rather than keep it for later work.
- One event opens one link. A second call for the same press answers
false. Calls from a plugin also count against its callback throttle.
It answers true when the link was handed to the client. That does not prove a page opened: a browser may
still block the new tab, in which case the web client offers the link to tap. It answers false when any
of the conditions above does not hold, and a Macro Deck or client that predates it always answers false.
Show the link some other way then, for example in one of your dialogs:
private async Task OpenArticleAsync(IUiEventOrigin origin, Uri link){ if (!await origin.OpenUrlAsync(link)) { await origin.ShowModalAsync(new ModalDefinition { ViewId = "article", Data = new Dictionary<string, JsonElement> { ["url"] = JsonSerializer.SerializeToElement(link.AbsoluteUri) } }); }}An IUiEventOrigin implemented before OpenUrlAsync existed keeps compiling: the default member checks
the URL the same way and answers false.
A plugin talking to a Macro Deck that predates origins still receives every event through this overload;
its origins answer false or a cancellation. In tests, pass a FakeUiEventOrigin from
MacroDeck.Plugin.Testing to Dispatch and read what the session showed from ReportedFailures,
OpenedModals and OpenedUrls.
While Macro Deck is locked
Section titled “While Macro Deck is locked”While the computer running Macro Deck is locked, a client’s events to a widget or dialog session of a
plugin, or of an integration served the same way, never reach the provider - the same rule that keeps
hardware presses and actions from running then. The client’s request is refused with HOST_LOCKED, and a
press, long press, double press, tap, change, adjust, swipe or end of a drag shows the client the same
“locked” failure a refused action shows. reveal is still delivered, so a widget can keep its content up to
date. You do not need a lock check of your own. On Linux the host cannot detect the lock and is never in the
locked state, so these events are always delivered there.
The lifecycle every surface shares
Section titled “The lifecycle every surface shares”| Step | What happens | Your side |
|---|---|---|
| Open | The host asks for a session for one surface, carrying the UI model version it speaks (request.UiModelVersion). |
Return a session or null. The session id is host-issued. |
| Snapshot | The host asks for a full tree when a client attaches and whenever it must resynchronise. | BuildTree() must describe the revision your emitted patches have reached. |
| Patches | You raise Changed; the host drains. |
Coalescing several changes into one raise is fine - the host drains rather than counts. A patch dropped in DrainPatches is lost to every attached client. |
| Events | Dispatch delivers a client event, never concurrently for one session. |
Reject an event by producing no patch. A throw faults the session. |
| Close | The host disposes the session - at any time: a client leaving for good, a limit trip, a fault, or .NET Hot Reload updating your plugin, after which clients open a new session. | Release what the session holds in DisposeAsync, including disposing its UiView. |
You never see who is attached, how many clients there are, or when one attaches: one code path serves one deck or several.
The host relays your bytes, not your objects. A tree or patch is bounded and forwarded verbatim, so unknown members, member order and number formatting reach the client exactly as you produced them.
Limits
Section titled “Limits”The maxUi* values are listed with every other protocol limit in
Plugin WebSocket protocol. The host advertises them in the
protocol descriptor and the session response - read them from there, never hard-code them.
| Limit | Measured as |
|---|---|
| Tree and patch size | UTF-8 bytes of the serialized payload. |
| Node count | Every node, including fallback subtrees. |
| Update rate and burst | Per session, not per provider - one busy view cannot starve another. |
maxUiResourceBytes |
Both a UiResource’s declared byteLength in a plugin’s tree and the bytes the host’s resource store accepts for one resource a plugin registers. A byteLength of null is accepted. |
maxUiResourceBytesPerPlugin / maxUiResourcesPerPlugin |
The bytes and the number of resources one plugin may have registered at once. |
maxUiWidgetSessionsPerProvider |
Open widget and preview sessions of one provider for one viewer (the device that owns them). The same deck on three devices can give your provider up to three times as many sessions. |
maxUiSessionsPerProvider |
Open folder and screensaver sessions of one provider for one viewer, and, as a separate count, every other surface (config, dialog, page) of one provider across all viewers. |
A session over one of these caps is refused with TOO_MANY_SESSIONS. Clients retry a refused widget, folder or screensaver session with backoff, because those slots free up as other sessions of the same viewer close.
What a refused update looks like
Section titled “What a refused update looks like”Nothing is dropped silently - no client is ever left on a revision that will never advance.
| You send | The host | The client sees |
|---|---|---|
| A patch over the byte limit, or too fast | Refuses it and asks you for a fresh tree. | That tree. |
A patch whose fromRevision does not match the session |
Refuses it and resyncs the same way. | Your current revision, through a full tree. |
| A patch with no operations, or one that does not advance the revision | Refuses it with INVALID_PAYLOAD. No resync - the revision never moved. |
Nothing; it is not stale. |
| A tree over the byte, node or declared-resource limit | Ends the session with PAYLOAD_TOO_LARGE. Nothing smaller can supersede a tree. |
The session ended. |
| Sustained overload after a resync | Ends the session with RATE_LIMITED. |
The session ended; retryable. |
Out of process, each refusal is an error on that call’s own host.result - the reply to the host.invoke
that carried the payload - so it is always attributable to the update that caused it.
Over the plugin protocol
Section titled “Over the plugin protocol”Out of process, the same contract is the ui capability kind. MacroDeck.Plugin.Hosting maps it onto
IUiProvider: register an integration that implements it and the SDK declares the kind, answers
describe from your Surfaces, and drives the snapshot, patch and fault callbacks from your session.
There is no capability handler to write.
Like the other provider-shaped capabilities, ui declares the single local id provider - the capability
is the plugin’s one UI provider. The host invokes kind: "ui", localId: "provider".
| Operation | Purpose |
|---|---|
describe |
The surfaces this provider serves, the UI model version it speaks, and the preview scenarios it declares. previews is optional: a plugin built against an SDK that predates it omits the key, and the host reads that as none. pages lists the integration pages it declares, optional the same way. |
session.open |
Open a session for one surface. The session id is host-issued; a provider never mints one. |
session.open (config) |
A config surface names its entry point in its surface attributes - integration-config with the config flow session, action-config with the action id and the instance’s stored parameters, folder-view-config with the folder and its view, or widget-config with the widget id, type and stored configuration. MacroDeck.Plugin.Hosting routes integration-config and action-config to IUiConfigFlow/IUiConfigurableActionDefinition before it consults IUiProvider. |
session.open (developer preview) |
A developer-preview surface names one registered scenario in its surface attributes. MacroDeck.Plugin.Hosting builds that scenario and never consults IUiProvider, so a production provider is unreachable from a preview. |
session.snapshot |
Produce the current full tree. The tree does not return on the result - it arrives as a separate host.invoke ui/snapshot, so a first attach and a resync share one delivery path. A provider may also send ui/snapshot unasked to replace its whole tree, at any revision; later patches continue from that tree’s revision. |
session.event |
A client acted on a node. clientId says which one, and is meaningful only for a shared session. MacroDeck.Plugin.Hosting hands it to your session as the event’s origin. A pointer-move still waiting to be sent is replaced by a newer one for the same node from the same client, and one the plugin could not take because it was busy or slow is dropped; see Pointer streams and taps. |
session.flush |
Answer once every session.event sent for the session before it has been handled, and every ui/report-parameter-values those events raised has been answered. The host sends it when a client is about to save an action. A plugin built before it answers CAPABILITY_UNSUPPORTED, which the host treats as flushed: such a plugin never reports values. |
session.close |
The host is ending this session. |
modal.result |
How a modal this plugin opened ended. Host-to-plugin because a person, not a timeout, bounds the wait - see Modal views. |
Trees, patches and faults travel the other way through the ui host api,
and so do an origin’s calls: origin.report-failure, origin.show-modal and origin.open-url name the
session and the clientId the event carried. The host acts on them only for a session the plugin serves and
a client still attached to it, and answers delivered: false or opened: false otherwise; origin.open-url
also needs the conditions in Opening a link on the client that pressed
and answers INVALID_PAYLOAD for a URL that is not http or https. A host that predates them answers
CAPABILITY_UNSUPPORTED, which the SDK turns into false or a cancellation.
origin.read-upload reads text a code input uploaded in one of the
plugin’s sessions. It answers found: false for any session the plugin does not serve, so one plugin never
reads another’s uploads, and sends the bytes after the reply as a ui-upload asset; see
the protocol reference. A host that predates it answers
CAPABILITY_UNSUPPORTED, which the SDK turns into NotSupportedException.
An action configuration session that reports parameter values (see
From an action) sends them as
ui/report-parameter-values. MacroDeck.Plugin.Hosting sends each report from the session’s own queue,
in order, and waits for its answer before it handles the next queued item, which is what lets
session.flush promise that the host already has them. A host that predates the operation answers
CAPABILITY_UNSUPPORTED; the SDK then drops reports for the rest of the connection and logs it once.
On the client side, a session’s reported values reach the attached client as
UiSessionParameterValuesReportedEvent (sessionId, values). A client asks with
FlushUiSession (sessionId, flushId), which is answered at once, and receives
UiSessionFlushedEvent (sessionId, flushId) on that connection alone once the session’s provider has
handled every event the client sent before, after every value those events reported. A host that predates
flushing refuses the request as an unknown type, so a client treats that as flushed.