Windows and Views, step by step

This tutorial builds on the project that dotnet new runic-app creates. Each stage adds one idea: ViewModels, the Window and its Views, opening the Window, rendering Views in the frontend, writing state from the frontend, and testing. Every command comes from the template, and every code block is an excerpt of the template or of an SDK example that the SDK's CI builds and runs.

The tutorial follows Runic SDK 0.7.0-preview.1. Differences from 0.6.0-preview.1 are marked where they occur.

1. Create the project

dotnet new install Runic.Application.Templates@<VERSION>
dotnet new runic-app --name MyApp --frontend react --package-manager npm --host cswebui --view-models toolkit
cd MyApp
dotnet tool restore
dotnet runic dev

Replace <VERSION> with the current release from the package catalog. These are the template defaults: React, npm, the CS-WebUI host and CommunityToolkit.Mvvm. The app opens with a Welcome page and a Counter page. Keep dotnet runic dev running; frontend edits reload in place and C# edits rebuild and restart the app.

2. ViewModels own the state

WorkspaceViewModel.cs contains ordinary CommunityToolkit.Mvvm ViewModels. Runic reads their public properties as state and their commands as calls the frontend may make. The Counter page is one property and one command:

public sealed partial class CounterViewModel : ObservableObject, IWorkspacePage
{
    private int _count;
    public int Count { get => _count; private set => SetProperty(ref _count, value); }

    [RelayCommand]
    private void Increment() => Count++;
}

The workspace selects the page. Main is ViewModel content: the frontend receives a reference to the presented ViewModel, not its state.

public IWorkspacePage Main
{
    get => _main;
    private set => SetProperty(ref _main, value);
}

[RelayCommand]
private void ShowWelcome() => Main = _welcome;

[RelayCommand]
private void ShowCounter() => Main = _counter;

Nothing here refers to Runic. The ViewModels stay testable and reusable.

3. The Window and its Views select what the frontend sees

Views.cs declares the contract. A Window is the root of one native or browser window; each View selects a ViewModel that a Window can present:

public sealed partial class WorkspaceWindow(CsWebUiBridgeWindow<WorkspaceViewModel> host)
    : CsWebUiWindow<WorkspaceViewModel>(host);

public sealed partial class WelcomeView : RunicView<WelcomeViewModel>;
public sealed partial class CounterView : RunicView<CounterViewModel>;

The build compiles the project, inspects these partial classes and writes one TypeScript module per ViewModel to Frontend/src/generated, for example counter.ts with CounterState and a typed client. The folder is ignored by Git and regenerated on every build.

With --host desktop the Window wraps a Runic Desktop window instead. It exposes it as Presentation, and its NativeOwner parents native file dialogs and the clipboard:

public sealed partial class WorkspaceWindow(DesktopBridgeWindow<WorkspaceViewModel> host)
    : RunicWindow<WorkspaceViewModel>(host.ViewModel), IAsyncDisposable
{
    public DesktopWindow Presentation => host.Presentation;
    // Pass to the Runic.Platform provider for this backend for native file dialogs and the clipboard, for
    // example WindowsPlatformProvider.CreateFileDialogs(NativeOwner) on Windows or, on Linux with GTK 3,
    // LinuxPlatformProvider.CreateFileDialogs(NativeOwner).
    public DesktopNativeOwner NativeOwner => host.NativeOwner;
    public ValueTask DisposeAsync() => host.DisposeAsync();
}

4. Register and open the Window

Program.cs registers the ViewModels with their lifetime and lets the generated AddRunicViews() register the Views and Bridges:

var services = new ServiceCollection();
services.AddScoped<WorkspaceViewModel>();
services.AddScoped<WelcomeViewModel>();
services.AddScoped<CounterViewModel>();
services.AddRunicViews();

The CS-WebUI host then opens the built frontend from the www folder next to the executable:

await using (var window = provider.OpenWindow<WorkspaceWindow, WorkspaceViewModel>(host => new WorkspaceWindow(host)))
{
    window.SetRootFolder(Path.Combine(AppContext.BaseDirectory, "www"));
    window.Show("index.html");
    WebUiApplication.Wait();
}

The Runic Desktop host passes the same folder as typed surface content:

await using var window = await provider.OpenDesktopWindowAsync<WorkspaceWindow, WorkspaceViewModel>(
    desktop,
    new DesktopSurfaceOptions { Content = new DesktopContent.Directory(Path.Combine(AppContext.BaseDirectory, "www"), "index.html") },
    host => new WorkspaceWindow(host),

DesktopContent is new in 0.7. In 0.6.0-preview.1 the same window sets RootFolder and Content = "index.html". To serve the frontend from an embedded archive instead of a folder, see Embed and serve assets.

5. Render the Views

The frontend connects the generated clients. In React, useView connects a client for the component's lifetime, useCommand runs a command and keeps its pending and error state, and ViewOutlet renders the page that Main references:

<!-- prettier-ignore -->
const workspace = { connect: connectWorkspace };
const pages = { counter: CounterPage, welcome: WelcomePage } satisfies ViewRegistry<WorkspaceState["main"]>;

export default function App() {
  const { state, client, error: connection } = useView(workspace);
  const navigate = useCommand((name: "showWelcome" | "showCounter") => client?.[name]());

The registry is checked against the generated union of page kinds, so adding a View without a page is a compile error. Each page connects the reference it receives:

<!-- prettier-ignore -->
export function CounterPage({ page }: { page: CounterPageReference }) {
  const { state, client, error: connection } = useView(page);
  const increment = useCommand(() => client?.increment());
  const error = increment.error ?? connection;

The other frameworks follow their own idioms. Vue's useView from @runic-artifex/vue accepts a ref or getter (MaybeRefOrGetter). Svelte imports from @runic-artifex/svelte/views and passes a getter, so the connection follows reactive state:

<!-- prettier-ignore -->
  const workspace = useView(() => ({ connect: connectWorkspace }));
  const navigate = useCommand((name: "showWelcome" | "showCounter") => workspace.client?.[name]());

Angular has injectView(), injectCommand() and RunicViewOutlet from @runic-artifex/angular.

The command helpers (useCommand, injectCommand) and the React and Vue ViewOutlet are new in 0.7. Svelte's ViewOutlet, Angular's RunicViewOutlet and ViewRegistry are already in 0.6.0-preview.1; 0.6 React and Vue projects select the page component themselves, and 0.6 projects call the client methods directly in try/catch.

6. Write state from the frontend

The First Window example extends the counter with a writable step. A public setter makes a property writable from the frontend:

[ObservableProperty] private int step = 1;

[RelayCommand]
private void Increment() => Count += Step;

The generated client gains setStep, which sends the value to .NET. The example uses the client without a framework:

<!-- prettier-ignore -->
step.addEventListener("blur", async () => {
  try {
    await counter.setStep(Number(step.value));
    status.textContent = "Step updated.";
  } catch (error) {
    status.textContent = String(error);

A rejected write or command rejects with the BridgeError from @runic-artifex/views.

7. Test the Window and the frontend

Both halves can be tested without a browser or a native window. The CommunityToolkit Notes example nests a sidebar, a document and an editor, and tests each side. Its .NET tests drive the real ViewModels and generated Bridges through RunicWindowTestHost from Runic.Application.Testing, with a fake clock:

Host = new RunicWindowTestHost<ShellViewModel>(
    window.GetRequiredService<ShellViewModel>(),
    window.GetRequiredService<Func<IBridgeTransport, WindowContentSession, ShellViewModel, IDisposable>>(),
    new RunicWindowTestHostOptions
    {
        ViewLocator = window.GetRequiredService<IRunicViewLocator>(),
        // The window graph shares the navigator's model context.
        ModelContext = window.GetRequiredService<IRunicModelContext>(),
        TimeProvider = clock,
    });

A test then reads and changes typed state the way the browser would:

var editor = await OpenEditorAsync();
editor.Set(vm => vm.Title, "Groceries").EnsureOk();
editor.Set(vm => vm.Body, "Milk, eggs").EnsureOk();

var save = editor.Start(vm => vm.SaveCommand);
Assert.Equal("accepted", save.Admission);

The frontend tests run the generated clients against generated typed mocks and the mock Bridge from @runic-artifex/views/mock. Renaming a ViewModel member breaks them at compile time:

<!-- prettier-ignore -->
beforeEach(() => { bridge = installMockBridge(createMockBridge()); });

test("a rejected field write stops the save that follows it", async () => {
  const editor = mockEditor(bridge, {
    state: draft,
    setters: { setTitle: (_state, title) => ({ isDirty: title !== "Untitled" }) },
    commands: { save: state => ({ isDirty: false, savedMessage: `Saved ${state.title}` }) },
  });
  const client = await connectEditor();

RunicWindowTestHostOptions, the typed Root driver and the generated *.mock.ts files are new in 0.7. In 0.6 the test host takes the root route name and exchanges JSON; see the Runic.Application.Testing README and the Views testing section.

Where next