Serving a configuration view
Serve a tree for an integration’s config flow or one configured action instance, beside the declared fields it never replaces.
Example
Section titled “Example”A config flow whose step is also drawn as a tree. The input keys match the declared field names:
public sealed class MediaServerIntegration : IPluginIntegration, IUiConfigFlowProvider{ public IReadOnlyList<IActionDefinition> Actions { get; } = [new ToggleAction()];
public IConfigFlow CreateConfigFlow() => new MediaServerConfigFlow();
public Task InitializeAsync(IIntegrationContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;}
public sealed class MediaServerConfigFlow : IUiConfigFlow{ private static ConfigFlowStep ConnectionStep() => new() { StepId = "connection", Title = Strings.Setup.ConnectionTitle(), Fields = [ ActionParameter.Secret("api_key", label: Strings.Setup.ApiKey(), required: true), ActionParameter.Number("poll_seconds", label: Strings.Setup.PollInterval(), min: 5, max: 3600, defaultValue: 30), ], };
public Task<ConfigFlowResult> StartAsync(IConfigFlowContext context, CancellationToken cancellationToken) => Task.FromResult(ConfigFlowResult.Step(ConnectionStep()));
public async Task<ConfigFlowResult> SubmitAsync(string stepId, IReadOnlyDictionary<string, object?> input, IConfigFlowContext context, CancellationToken cancellationToken) { var apiKey = input.GetValueOrDefault("api_key") as string ?? string.Empty;
return await MediaServerClient.CanConnectAsync(apiKey, cancellationToken) ? ConfigFlowResult.Complete("Media server") : ConfigFlowResult.Error(ConnectionStep(), Strings.Setup.CannotConnect()); }
public Task<IUiSession?> CreateUiSessionAsync(UiSessionRequest request, CancellationToken cancellationToken) { var apiKey = new UiState<string>(string.Empty); var pollSeconds = new UiState<double>(30);
var root = new UiConfigStack { Key = "root", Children = [ new UiSecretInput { Key = "api_key", Label = Strings.Setup.ApiKey(), Required = true, Binding = Bind.To(apiKey), }, new UiNumberInput { Key = "poll_seconds", Label = Strings.Setup.PollInterval(), Description = Strings.Setup.PollIntervalHint(), Min = 5, Max = 3600, ShowSlider = true, Binding = Bind.To(pollSeconds), }, ], };
return Task.FromResult<IUiSession?>(new ViewSession(new UiView(request.Surface, root))); }}ViewSession is the adapter from Serving a view.
The tree renders a transaction it does not own
Section titled “The tree renders a transaction it does not own”// Declared fields stay - they are the fallback and they mark secrets.ActionParameter.Secret("api_key", label: Strings.Setup.ApiKey(), required: true)
// The tree's top-level input with the same key feeds the same submit.new UiSecretInput { Key = "api_key", Label = Strings.Setup.ApiKey(), Binding = Bind.To(apiKey) }A configuration tree never completes a flow and never persists a parameter. SubmitAsync stays the only
way a flow accepts values, and the ordinary save path the only way an action instance is written - which
keeps secret encryption, OAuth and entry replacement unchanged
(ADR 0050).
- Keep serving the declared fields.
ConfigFlowStep.FieldsandIActionDefinition.Parametersare still required: they are the fallback, and for a config flow they tell Macro Deck which submitted values are secret. - Name top-level inputs after their fields. A top-level input’s node id is the field key it submits.
From a config flow
Section titled “From a config flow”public sealed class MediaServerIntegration : IPluginIntegration, IUiConfigFlowProvider { /* ... */ }
public sealed class MediaServerConfigFlow : IUiConfigFlow{ public Task<IUiSession?> CreateUiSessionAsync(UiSessionRequest request, CancellationToken cancellationToken) => Task.FromResult<IUiSession?>(new ViewSession(new UiView(request.Surface, BuildTree())));}IUiConfigFlowProvider goes on the integration; describe then reports servesConfigUiTree.
IUiConfigFlow goes on the flow object itself, so the tree’s state and SubmitAsync’s state are one
thing. Macro Deck relays the tree without inspecting it, so it cannot tell which tree values are secret:
a flow serving a tree classifies its own secrets by returning them as ConfigFlowValue.Secret from
Complete.
From an action
Section titled “From an action”public sealed class ToggleAction : IUiConfigurableActionDefinition{ public string Id => "toggle";
public LocalizedText Name => Strings.Toggle.Name();
public LocalizedText Description => Strings.Toggle.Description();
public IReadOnlyList<ActionParameter> Parameters { get; } = [ ActionParameter.Text("target", label: Strings.Toggle.Target(), required: true), ActionParameter.Text("mode", label: Strings.Toggle.Mode(), defaultValue: "toggle"), ];
public IActionExecutor CreateExecutor() => new ToggleExecutor();
public Task<IUiSession?> CreateConfigurationSessionAsync(ActionConfigurationRequest request, CancellationToken cancellationToken) { var target = new UiState<string>(Read(request, "target") ?? string.Empty); var mode = new UiState<string>(Read(request, "mode") ?? "toggle");
var root = new UiConfigStack { Key = "root", Children = [ new UiStringInput { Key = "target", Label = Strings.Toggle.Target(), Required = true, Binding = Bind.To(target) }, new UiChoiceInput { Key = "mode", Label = Strings.Toggle.Mode(), Segmented = true, Binding = Bind.To(mode), Options = UiValue.Of<IReadOnlyList<UiOption>>([ UiOption.Of("on", Strings.Toggle.ModeOn()), UiOption.Of("off", Strings.Toggle.ModeOff()), UiOption.Of("toggle", Strings.Toggle.ModeToggle()), ]), }, ], };
return Task.FromResult<IUiSession?>(new ViewSession(new UiView(request.Session.Surface, root))); }
private static string? Read(ActionConfigurationRequest request, string name) => request.Parameters.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;}One session is created per open configuration surface, so several can be live for the same action at
once - keep state on the session, never on the definition. request.Parameters holds the instance’s
stored values, keyed by parameter name. Secret and Password parameters arrive masked
(UiConfigSurfaceAttributes.MaskedSecretValue, "$masked"), because opening a configuration surface is
not an intent to reveal a secret. You get the real value the ordinary way, when the user submits.
Declining and fallback
Section titled “Declining and fallback”public Task<IUiSession?> CreateUiSessionAsync(UiSessionRequest request, CancellationToken cancellationToken) => Task.FromResult<IUiSession?>(null); // the client renders the declared fields| What happens | What the user gets |
|---|---|
You return null |
The declared fields. Not an error. |
| The provider times out, disconnects or trips a session limit | The declared fields, never an error. |
| The client cannot render your UI model version | The declared fields. Negotiated before any session opens - no session, no slot. |
A client renders one representation, never a mix of both.
Entry points
Section titled “Entry points”A config surface names its entry point in UiConfigSurfaceAttributes.EntryPoint:
UiConfigEntryPoints |
Attributes | Served by |
|---|---|---|
IntegrationConfig (integration-config) |
IntegrationId, ConfigFlowSessionId |
IUiConfigFlow.CreateUiSessionAsync |
ActionConfig (action-config) |
ActionId, Parameters |
IUiConfigurableActionDefinition.CreateConfigurationSessionAsync |
FolderViewConfig (folder-view-config) |
FolderId, FolderViewId, FolderViewConfiguration |
Your IUiProvider - see Folder views |
ScreenSaverConfig (screensaver-config) |
DeviceId, ScreenSaverId, ScreenSaverConfiguration |
Your IUiProvider - see Screensavers |
WidgetConfig (widget-config) |
WidgetId, WidgetType, WidgetData, WidgetWidth, WidgetHeight |
Your IUiProvider - see Configuring a widget |
Out of process, MacroDeck.Plugin.Hosting routes integration-config and action-config to the flow or
action first; if that target does not exist, serves no tree or declines, it falls through to your
IUiProviders. Folder views and widgets have no declared field list, so their rules differ - see their
pages.