Runic.Application.Views
0.8.0-preview.5
dotnet add package Runic.Application.Views --version 0.8.0-preview.5
NuGet\Install-Package Runic.Application.Views -Version 0.8.0-preview.5
<PackageReference Include="Runic.Application.Views" Version="0.8.0-preview.5" />
<PackageVersion Include="Runic.Application.Views" Version="0.8.0-preview.5" />
<PackageReference Include="Runic.Application.Views" />
paket add Runic.Application.Views --version 0.8.0-preview.5
#r "nuget: Runic.Application.Views, 0.8.0-preview.5"
#:package Runic.Application.Views@0.8.0-preview.5
#addin nuget:?package=Runic.Application.Views&version=0.8.0-preview.5&prerelease
#tool nuget:?package=Runic.Application.Views&version=0.8.0-preview.5&prerelease
Runic.Application.Views
Typed .NET Windows and Views with generated TypeScript clients. C# ViewModels
own application state, commands, and operation lifetimes; a React, Vue,
Svelte, Angular, or plain TypeScript frontend renders them through a generated
client. Every package's ID is its namespace: this package's types are in
Runic.Application.Views, and the host adapters' in
Runic.Application.Views.Desktop, Runic.Application.Views.CsWebUi and
Runic.Application.Views.Wpf.
Install
Reference a host adapter; it brings this package and its build targets:
dotnet add package Runic.Application.Views.CsWebUi --prerelease # CS-WebUI browser or WebView window
dotnet add package Runic.Application.Views.Desktop --prerelease # or: Runic Desktop native host
dotnet add package CommunityToolkit.Mvvm # or ReactiveUI with Runic.Application.Views.ReactiveUI
dotnet add package Microsoft.Extensions.DependencyInjection # ServiceCollection; the adapters need only the abstractions
To start a new application, run the
guided creator
(dnx Runic.Create@<VERSION>) or the dotnet new runic-app template, then
dotnet tool restore and dotnet runic dev. The template defaults to a native
Runic Desktop window and ReactiveUI ViewModels; the minimal Window below keeps
to the shortest program, CS-WebUI (which opens a browser window) with
CommunityToolkit.Mvvm, as in the First Window example.
Glossary
These words name one concept each across the Runic packages and frontends.
- Window: a top-level application window and its root ViewModel, declared
as a partial
RunicWindow<TViewModel>. The same declaration opens on every host withhost.OpenWindowAsync<TWindow>(). A Window is also a View. Runic Desktop'sDesktopWindowis the native window underneath one. - View: a logical .NET presentation object for one ViewModel, a partial
RunicView<TViewModel>with a typedDataContext. It owns no DOM: the frontend renders a component for it, whichViewOutletselects in React, Vue, Svelte and Angular. - Bridge: the code generated per ViewModel, C# and a typed TypeScript
client, that carries snapshots, property writes, commands and operations
between the ViewModel and its View over the wire protocol.
Bridge*types and theRUNICBRIDGEdiagnostics belong to this generated layer. - Host: the adapter that opens Windows on a platform:
RunicDesktopHost(Runic.Application.Views.Desktop) andRunicCsWebUiHost(Runic.Application.Views.CsWebUi), both anIRunicWindowHost. In WPF,RunicViewHosthosts one View inside the visual tree. An open Window'sHostproperty returns its per-Window adapter, anIBridgeWindow. - Presentation: what shows a Window's or View's web content on screen: an
embedded WebView or an installed browser window, such as
DesktopBridgeWindow.Presentation(aDesktopWindow) or the WebView of aRunicViewHost. A presentation closes and reopens without changing the ViewModel. - Surface: Runic Desktop's
DesktopSurface, which serves a Window's web content (DesktopSurfaceOptions.Content), capabilities and browser sessions, and opens presentations of it.DesktopBridgeWindow.Surfaceis the Window's surface. - Owner: the native window that platform services, such as file dialogs,
the clipboard and file launchers, attach to:
DesktopNativeOwnerfor a Runic Desktop window, or anINativePickerOwnerfor another host. Native dialogs require one (OwnerPolicy.RequireOwner).
A minimal Window
In a project named MyApp:
// Counter.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Runic.Application.Views;
namespace MyApp;
public sealed partial class CounterViewModel : ObservableObject
{
// A private setter keeps Count read-only to the page; Increment changes it.
[ObservableProperty]
public partial int Count { get; private set; }
[RelayCommand]
private void Increment() => Count++;
}
// Selecting the Window makes the build generate Frontend/src/generated/counter.ts.
public sealed partial class CounterWindow : RunicWindow<CounterViewModel>;
// Program.cs
using Microsoft.Extensions.DependencyInjection;
using MyApp;
using Runic.Application.Views;
using Runic.Application.Views.CsWebUi;
var services = new ServiceCollection();
services.AddScoped<CounterViewModel>();
services.AddRunicViews();
using var provider = services.BuildServiceProvider();
// RunicDesktopHost.Run(provider, ...) from Runic.Application.Views.Desktop runs the same application natively.
return RunicCsWebUiHost.Run(provider, async host =>
{
await using var window = await host.OpenWindowAsync<CounterWindow>();
await window.WaitForCloseAsync();
return 0;
});
import { connectCounter } from "./generated/counter.js";
const counter = await connectCounter();
counter.subscribe(state => { document.querySelector("#count")!.textContent = String(state.count); });
document.querySelector("#increment")!.addEventListener("click", () => void counter.increment());
Generated modules import the shared browser runtime, so the frontend installs
@runic-artifex/views (npm install @runic-artifex/views@preview) and bundles
its entry. connectCounter() returns a CounterClient; subscribe delivers
the current state first, calls reject with the runtime's BridgeError, and
dispose() releases the connection. @runic-artifex/react,
@runic-artifex/vue, @runic-artifex/svelte (useView) and
@runic-artifex/angular (injectView()) bind clients to component
lifetimes.
Frontend/index.html loads webui.js and runic-views.js before the module.
The host serves webui.js, and the host package copies its Views client into
www/ as runic-views.js, so the page names no host and one frontend build runs
on Runic Desktop and CS-WebUI (see The host of a page). The
First Window example
is this application with a complete frontend.
Windows, Views, and composition
A partial RunicWindow<TViewModel> declares a window and its root ViewModel; a
partial RunicView<TViewModel> selects the content presented for a ViewModel
property. [RunicViewContract("name")] adds a second View for the same
ViewModel. The build compiles the application, inspects these classes after the
MVVM source generators run, and generates C# attachments and one ordinary
TypeScript module per ViewModel.
A View is a logical .NET presentation object with typed DataContext. Its
browser counterpart can be a plain TypeScript function or a framework
component. The browser acknowledges mount and unmount, so a content session
creates a fresh View for each presentation and releases it when the outlet
changes. The ViewModel belongs to its application or window DI scope and may
survive View changes. Window-local routes and operation admission are
host-neutral; native window creation belongs to the host adapter.
The generated composition class (<ProjectName>.RunicBridgeComposition,
or RunicApplicationFrontendCompositionType) provides two registrations:
AddRunicViews()registers the generated Bridges, every non-Window View as transient, andServiceProviderViewLocatoras the defaultIRunicViewLocator. Register an earlierIRunicViewLocatoror View to replace a default.AddRunicBridges()registers only the Bridges, for applications that create Views themselves or use another locator, such asReactiveRunicViewLocator.
ViewModels stay explicit registrations because their lifetime is an application decision; a Window's root ViewModel must be scoped. The registrations are generated code, so they need no reflection and are NativeAOT-safe.
The window binds its root ViewModel to the window's model context, and a Bridge
attached directly to a WindowContentSession binds its ViewModel for the
Bridge's lifetime, so no ViewModel needs a manual
RunicModelContextRegistry.Bind. A root, content or Bridge ViewModel already
bound to a different context is rejected with an InvalidOperationException
that names its type.
Presented content is bound to the window's model context, and keeps its
checked-field registries, only while it is attached. Replacing, clearing, or
pruning it releases both; the window keeps a weak identity so presenting the same
object again returns the same reference. When the browser still shows a suspended
reference, as after Main = b; Main = a; in one command, its mount resumes on the
re-attached bridge with a fresh View. Snapshot revisions increase across the whole
window, so a re-attached route never publishes an older revision. The
wire protocol
describes these rules.
Generated clients expose a snapshot, subscriptions, typed property setters, commands, and disposal. Calls reject with typed errors. Accepted operations remain owned by .NET across a browser reload; a reconnect gets a new snapshot. A transport failure after acceptance can leave completion unknown, so callers should inspect state before retrying a non-idempotent command. The generated field-write API returns receipts and version conflicts; frontend form helpers must await pending edits before issuing Save.
The runtime emits state and replies with generated and explicit
Utf8JsonWriter code. The build tool inspects the compiled application;
release runtime serialization does not reflect over ViewModel members. The
first-window example is exercised through Native AOT and Chromium in CI. The
generator's errors are listed under Generator diagnostics.
Opening a Window
Every host opens Windows the same way. The application's entry point calls the
host's Run method, RunicDesktopHost.Run (Runic.Application.Views.Desktop) or
RunicCsWebUiHost.Run (Runic.Application.Views.CsWebUi), which runs the
asynchronous application on the event loop the host needs and passes it an
IRunicWindowHost:
static async Task<int> RunAsync(IRunicWindowHost host)
{
await using var window = await host.OpenWindowAsync<MainWindow>(new RunicWindowOptions { Width = 1000, Height = 700 });
await window.WaitForCloseAsync();
return 0;
}
Switching host changes only the Run call, its package and its host options;
the Window declaration and this body stay the same, with ReactiveUI or
CommunityToolkit.Mvvm ViewModels.
OpenWindowAsync<TWindow>(options, cancellationToken)creates the Window, checks that the generated Bridge is registered, resolves the root ViewModel in a new DI scope, assigns it toDataContext, and opens the native window.RunicWindowOptionssets the content root (by defaultwwwnext to the application), the start page (index.html) and the initial size on every host. The Window owns the scope until it is disposed. A Window instance opens once.ValidateWindow<TWindow>(options)runs the same checks at startup without opening anything and returns aRunicWindowValidationResult: an errorbridge-not-registeredwhenAddRunicViews()is missing, a warningviewmodel-not-registeredwhen the container does not report the ViewModel, and the host's own checks, such as Runic Desktop's window options.ThrowIfInvalid()throws aRunicWindowConfigurationException, the same exceptionOpenWindowAsyncthrows on every host.WaitForCloseAsync(cancellationToken)completes when the window closes or starts closing; the token stops only the wait.CloseAsync(timeout, cancellationToken)stops new operations, waits up totimeoutfor accepted ones, and returns aBridgeWindowCloseResult; its token also stops only the caller's wait. Disposal is asynchronous only.RequestCloseAsync(cancellationToken)closes the window as its close button does and returns whether it closed; withGuardCloseit asksCanCloseAsyncfirst (see Guarding a user close).Hostreturns the host adapter, anIBridgeWindow:DesktopBridgeWindow<TViewModel>(withSurface,PresentationandNativeOwner) orCsWebUiBridgeWindow<TViewModel>(withNativeWindow).
Opening a Window from a ViewModel
The host is passed only to the Run callback. To open Windows from a command
or a service, register IRunicWindowService and take it in a constructor:
services.AddRunicWindowService();
With ReactiveUI, as in the multi-window example:
public sealed class ProjectViewModel : ReactiveObject
{
public ProjectViewModel(IRunicWindowService windows)
{
// Opens another Window on the same project, in its own scope. The service releases it when it closes.
NewWindowCommand = ReactiveCommand.CreateFromTask(async token =>
{
await windows.OpenWindowAsync<ProjectWindow>(cancellationToken: token);
});
}
public ReactiveCommand<RxVoid, RxVoid> NewWindowCommand { get; }
}
With CommunityToolkit.Mvvm:
public sealed partial class ProjectViewModel(IRunicWindowService windows) : ObservableObject
{
[RelayCommand]
private async Task NewWindowAsync() => await windows.OpenWindowAsync<ProjectWindow>();
}
OpenWindowAsync<TWindow>(options, cancellationToken)has the same parameters and checks as the host's, and opens the Window in its own DI scope on the host that runs the application, on Runic Desktop and CS-WebUI alike. On CS-WebUI,WaitForCloseAsynccompletes only when every browser window has closed.- The service is a singleton, so ViewModels in every Window and singleton
services can take it. A host binds it before it resolves the ViewModel of the
first Window it opens, so it already works in that ViewModel's constructor.
Before any Window has opened it throws
InvalidOperationException. - The service releases each Window it opened, with its scope and ViewModels, once the Window closes, so a command doesn't need to keep it. Keep the returned Window to wait for it or to close it; disposing it earlier is safe. A release failure is logged as event 1051.
- Runic has no owned or modal Windows, so there is no owner parameter. The new Window's ViewModel is resolved from DI like the first one's; to give it input, let it read a singleton service, as the multi-window example does with its shared project.
- The interface has one method, so a test registers a fake that records the requested Window types after the application's services.
Windows from another service provider
A host and its window service open Windows from one service provider. A part of the application with services of its own, such as a dashboard that a setup assistant starts in the same process once it has created what the dashboard needs, builds its own provider and creates a host for it on the running Desktop host:
await using var dashboardServices = new ServiceCollection()
.AddDashboard() // its ViewModels, AddRunicViews() and AddRunicWindowService()
.BuildServiceProvider();
var dashboard = new RunicDesktopHost(dashboardServices, setupHost.Desktop);
await using var window = await dashboard.OpenWindowAsync<DashboardWindow>();
await window.WaitForCloseAsync();
setupHost is the RunicDesktopHost the application runs on, and Desktop
its event loop's DesktopHost; a RunicDesktopHost created in
DesktopEventLoop.Run(options, desktop => ...) takes that desktop. On
CS-WebUI, use new RunicCsWebUiHost(dashboardServices). The
IRunicWindowService of the second provider follows the second host once it
has opened a Window, so the dashboard's ViewModels open further Windows from
their own services. Both hosts share the event loop and its lifetime.
A Window with its own page
Every Window opens index.html unless RunicWindowOptions.StartPage names
another page. With Vite, each page is a build input, and listing inputs replaces
the default index.html, so keep it in the list. The runic-app template's
vite.config.ts already lists it; add the second page next to it:
// Frontend/vite.config.ts (Vite 8)
export default defineConfig({
base: "./",
plugins: [svelte(), runic()],
build: {
rolldownOptions: { input: ["index.html", "settings.html"] },
outDir: "dist",
emptyOutDir: true,
target: "es2022"
}
});
Create Frontend/settings.html beside index.html, for example as a copy that
loads its own entry script, and open it with:
await windows.OpenWindowAsync<SettingsWindow>(new RunicWindowOptions { StartPage = "settings.html" });
The build writes www/settings.html beside www/index.html, and
dotnet runic dev serves both. Vite resolves inputs against its root, the
frontend directory. The object form, input: { main: "index.html", settings: "settings.html" }, names the output chunks; rollupOptions still works in
Vite 8 but is deprecated. A page in a folder, such as pages/settings.html,
works the same way with StartPage = "pages/settings.html":
@runic-artifex/vite-plugin-runic points its host scripts (webui.js and the
Runic client script) at the web root.
A page that the build does not emit fails before a window waits for it: on
Runic Desktop, opening the Window throws RunicWindowConfigurationException
with the code content-entry-not-found, which names the page and the pages the
content root has; dotnet runic dev stops with RAPPDEV1009 when a build
input's file is missing. CS-WebUI does not check the page before it opens
the window.
Guarding a user close
A Window that sets GuardClose asks CanCloseAsync before a close the user
starts (the title-bar close button, Alt+F4 or the window manager) and stays open
when it returns false, like Window.Closing with e.Cancel = true in WPF and
Avalonia, but asynchronous:
public sealed partial class EditorWindow : RunicWindow<EditorViewModel>
{
public EditorWindow() => GuardClose = true;
}
The declaration is the same for ReactiveUI and CommunityToolkit.Mvvm
ViewModels; only the guards and the commands below differ.
The default CanCloseAsync clears each NavigationRegion<T> the Window's
ViewModel exposes, in declaration order, so the departure guards of their
entries decide, as WPF's NavigationHost.GuardWindowClose does (see
Navigation). The clears pass
Admission = NavigationAdmission.Supersede, so a region created with
RejectWhileBusy is cleared during a request instead of refusing the close as
Busy. Override it to add a check of your own,
and call the base method to keep the navigation guards. It starts in a turn of
the window's model context, one decision runs at a time, and it may wait as
long as a prompt needs; the event loop is never blocked. An exception keeps the
window open and is logged as event 1052.
A guard can ask the user in the page: a ReactiveUI interaction it handles, such
as ReactiveLeaveConfirmation.WithInteraction, reaches the web View's mounted
handler when the Window has exactly one presentation with that handler
mounted. It never reaches another Window. If the View unmounts or the Window
closes during the question, the guard's answer is "keep", so the Window stays
open (see
Leave confirmation in a web View).
To close a Window from code the way the close button does, call
RequestCloseAsync(cancellationToken). It returns true when the window
closed and false when CanCloseAsync kept it open, like Window.Close()
in WPF and Avalonia, which raises the cancellable Closing, but awaitable.
Without GuardClose it closes. Keep the Window that OpenWindowAsync
returned, including from IRunicWindowService, to call it from a command; a
ViewModel closes its own Window through IRunicWindowContext (see
Closing a ViewModel's own Window).
With ReactiveUI, the command passes its token to the request. The Bridge starts
the command in a turn of its own Window's model context, so the body sets state
directly after await:
CloseEditorCommand = ReactiveCommand.CreateFromTask(async token =>
{
if (!await _editor.RequestCloseAsync(token)) StatusText = "The editor has unsaved changes.";
});
With CommunityToolkit.Mvvm:
[RelayCommand]
private async Task CloseEditorAsync()
{
if (!await _editor.RequestCloseAsync()) StatusText = "The editor has unsaved changes.";
}
A request while another request or a user close is being decided waits for the same decision; its token stops only the caller's wait. When the window may close, the visible window closes at once and disposal releases its scope, as after a user close.
CloseAsync, disposal and a host shutdown (Ctrl+C, SIGTERM) are the application's own close: they do not ask, and they cancel a pending decision's token. A pendingRequestCloseAsyncthen returnstrue.- On Runic Desktop the embedded window asks, and
RequestCloseAsyncis its native close request. A Window that also hasWindowOptions.ConfirmCloseAsyncasks its own guard first, then that confirmation, for both. - A presentation that cannot keep its window open opens the Window unguarded and
logs event 1053
WindowCloseGuardUnavailableinstead of failing: CS-WebUI, a browser window (an explicitly selected browser or theEmbeddedThenBrowserfallback), or a custom embedded-window host without theCloseConfirmationcapability. A browser tab or window closes without asking the application. Save drafts as the user edits, or handle the close afterwards, where that matters.RequestCloseAsyncstill asks there, because the application is the one closing.
Closing a ViewModel's own Window
A Quit or Done command closes the Window its ViewModel belongs to through
IRunicWindowContext, which AddRunicWindowService() registers per scope.
OpenWindowAsync binds it to the Window it opens before it resolves the
Window's ViewModel, so the ViewModel takes it in its constructor and needs no
holder that the code calling OpenWindowAsync fills in:
services.AddRunicWindowService();
With ReactiveUI:
public sealed partial class EditorViewModel : ReactiveObject
{
public EditorViewModel(IRunicWindowContext window)
{
// Closes this Window as its close button does: GuardClose and the navigation guards decide.
QuitCommand = ReactiveCommand.CreateFromTask(async token =>
{
if (!await window.RequestCloseAsync(token)) StatusText = "Save or discard the draft first.";
});
}
[Reactive] public partial string StatusText { get; private set; } = "";
public ReactiveCommand<RxVoid, RxVoid> QuitCommand { get; }
}
With CommunityToolkit.Mvvm:
public sealed partial class EditorViewModel(IRunicWindowContext window) : ObservableObject
{
[ObservableProperty]
public partial string StatusText { get; private set; } = "";
[RelayCommand]
private async Task QuitAsync()
{
if (!await window.RequestCloseAsync()) StatusText = "Save or discard the draft first.";
}
}
RequestCloseAsync(cancellationToken)is the Window's ownRequestCloseAsync: it asksCanCloseAsyncwhen the Window setsGuardClose, returnsfalsewith the Window open when that keeps it, andtrueonce it closed. It works on Runic Desktop and on CS-WebUI, where the request is guarded although the browser's own close is not.- The context belongs to the Window's DI scope. A scoped service that the
ViewModel takes shares it; a navigation entry created with
RunicNavigatorOptions.CreateEntryScopeshas a scope of its own, so pass the context to it from the Window's ViewModel. Resolved outside a Window's scope, or called before the Window has opened, for example from the ViewModel's constructor, it throwsInvalidOperationException. - The interface has one method, so a test registers a fake that answers
trueorfalseafter the application's services, or opens the Window and lets its guards decide.
Menu bar commands
A Window's root ViewModel runs the menu bar's commands while its Window is the
key window by implementing IRunicMenuCommandSource. The host's menu bar
(Runic Desktop's DesktopHostOptions.MenuBar) names each command by id:
public sealed partial class EditorViewModel : ObservableObject, IRunicMenuCommandSource
{
public ICommand? GetMenuCommand(string commandId) => commandId switch
{
"file.save" => SaveCommand,
"edit.find" => Find.OpenCommand, // a child ViewModel's command
_ => null,
};
}
The host asks once for each id when the Window opens, and again after
DesktopHost.SetMenuBar, in the ViewModel's model turn. An item is enabled
while its command's CanExecute(null) is true, following CanExecuteChanged,
and runs Execute(null) in the model turn; a failure is logged as event 2004,
DesktopMenuCommandFailed. An id the ViewModel returns null for goes to the
application's commands, RunicDesktopHost.MenuCommands, such as New or
Settings…, which also run while no Window is open. Runic Desktop shows the menu
bar on macOS; elsewhere, and on CS-WebUI, render menus in the page, bound to the
same commands.
Keyboard shortcuts in the page
In-window shortcuts belong to the page: handle keydown and run the generated
command, as a button would. matchesShortcut and formatShortcut from
@runic-artifex/views read the same shortcut text as DesktopShortcut, so one
Primary+S is ⌘S on macOS and Ctrl+S on Windows and Linux:
import { formatShortcut, matchesShortcut } from "@runic-artifex/views";
addEventListener("keydown", (event) => {
if (matchesShortcut(event, "Primary+S") && editor.state.canSave) {
event.preventDefault();
void editor.save();
}
});
saveButton.title = `Save (${formatShortcut("Primary+S")})`;
saveButton.setAttribute("aria-keyshortcuts", "Control+S Meta+S");
- Primary, not Ctrl.
PrimaryismetaKeyon Apple platforms andctrlKeyelsewhere; matching requires exactly the shortcut's modifiers, and keys an input method is composing never match. - Text editing comes first. Leave Undo, Cut, Copy, Paste, Select All and
arrow-key movement to text fields; check
event.targetbefore taking a key that editing uses. - Keys the window keeps. The operating system and web view reserve some
keys: ⌘Q, ⌘W, ⌘H and ⌘M on macOS, Alt+F4 on Windows, F12 and the zoom keys in
embedded windows. A key the page handles with
preventDefault()does not reach a menu item with the same shortcut, so declare a command's shortcut on the macOS menu item or in the page, not both. - Make shortcuts discoverable. Show them beside the control or in the
page's menus with
formatShortcut, and setaria-keyshortcutsso screen readers announce them. Global shortcuts, which work while the application is in the background, are not supported yet.
Receiving dropped files
A Window's root ViewModel receives the files the user drops onto its window,
with their real paths, by implementing IRunicFileDropTarget:
public sealed partial class EditorViewModel : ObservableObject, IRunicFileDropTarget
{
public async ValueTask OnFilesDroppedAsync(RunicFileDrop drop, CancellationToken cancellationToken)
{
foreach (var file in drop.Files.Where(static file => !file.IsDirectory))
await OpenAsync(file.Path, cancellationToken);
}
}
The host calls it in the ViewModel's model turn, as a command runs, once per
drop and in drop order; the token is cancelled when the Window closes. A failure
is logged as event 1054, WindowFileDropFailed, and the Window stays open.
RunicFileDrop.Files lists each dropped file or directory as a
RunicDroppedFile with its absolute Path, Name and IsDirectory.
The paths come from the native window, never from the page. Runic Desktop's
embedded window (WebView2, WKWebView, GTK 3 and GTK 4) and the WPF child web
View deliver drops; CS-WebUI and an installed browser have no native window
that could, so there the page only gets HTML5 File objects without paths. The
page keeps its own dragenter, dragover and drop events, so it can
highlight a drop target, and dropping a file never navigates the Window away.
To tell drop areas apart, mark an element with data-runic-drop-target:
<li data-runic-drop-target="folder:42">Invoices</li>
The nearest marked element under the drop becomes RunicFileDrop.Target. The
page reports it, so treat it as an untrusted routing label: never as a path,
and never for an access decision. In a Flatpak sandbox the paths are
document-portal paths under /run/user/<uid>/doc, which the application can
open; a macOS App Sandbox grants access to dropped files while the application
runs.
Moved and renamed since 0.7.0-preview.6
The Window types and entry points of the two hosts were merged into this API,
and package, build property and outlet names now match what they are. Earlier
API shapes were removed rather than deprecated. Old build property names still
work in 0.7.0-preview.7 with warning
RUNICBRIDGE017,
and Angular's RunicViewOutlet remains a deprecated alias; both are removed in
the next preview. The old package IDs get no further releases, so replace them
in PackageReference and Directory.Packages.props; namespaces are unchanged.
| Before | Now |
|---|---|
RunicWindow<T>(T dataContext) constructor |
RunicWindow<T>(); OpenWindowAsync assigns DataContext |
ReactiveRunicWindow<T> (Runic.Application.ReactiveUI, .Reactive) |
RunicWindow<T>; Views keep ReactiveRunicView<T> |
CsWebUiWindow<T> |
RunicWindow<T> |
provider.OpenWindow<TWindow, TViewModel>(factory) (CS-WebUI) |
await host.OpenWindowAsync<TWindow>(options) on RunicCsWebUiHost |
provider.OpenDesktopWindowAsync<TWindow, TViewModel>(desktop, surfaceOptions, factory, windowOptions) |
await host.OpenWindowAsync<TWindow>(options) on RunicDesktopHost (SurfaceOptions, WindowOptions) |
provider.ValidateWindow<TViewModel>(), which threw |
host.ValidateWindow<TWindow>(options), which returns RunicWindowValidationResult |
provider.ValidateDesktopWindow<TViewModel>(desktop, windowOptions) |
host.ValidateWindow<TWindow>(options) |
CsWebUiConfigurationException (Code, DiagnosticMessage, Remediation) |
RunicWindowConfigurationException (Diagnostics) |
DesktopConfigurationException from OpenDesktopWindowAsync |
RunicWindowConfigurationException, with the Desktop exception as InnerException |
DesktopEventLoop.Run(options, async desktop => ...) around OpenDesktopWindowAsync |
RunicDesktopHost.Run(provider, options, async host => ...) |
WebUiApplication.Wait() and WebUiApplication.Clean() in Program.cs |
RunicCsWebUiHost.Run(provider, async host => ...) |
window.Presentation.WaitForClose(), WebUiApplication.Wait() |
await window.WaitForCloseAsync(cancellationToken) |
IBridgeWindow.CloseAsync(TimeSpan) |
IBridgeWindow.CloseAsync(TimeSpan, CancellationToken) and WaitForCloseAsync(CancellationToken) |
SetRootFolder, SetSize, SetPort, Show, ShowWebView, StartServer on CsWebUiBridgeWindow<T> and CsWebUiWindow<T> |
RunicWindowOptions, and UseWebView, ConfigureWindow and ShowWindow on RunicCsWebUiHost; NativeWindow remains |
Log events 1050 CsWebUiWindowRegistrationMissing and 2001 DesktopWindowRegistrationMissing |
Event 1050 WindowRegistrationMissing on every host |
Package Runic.Application |
Runic.Application.Views, its namespace |
Package Runic.Application.CsWebUi |
Runic.Application.Views.CsWebUi |
Package Runic.Application.Desktop (assembly Runic.Application.Desktop.dll) |
Runic.Application.Views.Desktop (Runic.Application.Views.Desktop.dll) |
Package Runic.Application.Wpf (assembly Runic.Application.Wpf.dll) |
Runic.Application.Views.Wpf (Runic.Application.Views.Wpf.dll) |
Package Runic.Application.ReactiveUI |
Runic.Application.Views.ReactiveUI |
Package Runic.Application.ReactiveUI.Reactive |
Runic.Application.Views.ReactiveUI.Reactive |
Log category Runic.Application.Desktop (Desktop transport) |
Runic.Application.Views.Desktop |
RunicBridgeFrontendDir |
RunicApplicationFrontendDirectory |
RunicBridgeTypescriptDir |
RunicApplicationFrontendGeneratedDirectory |
RunicBridgeFrontendPackageManager |
RunicApplicationFrontendPackageManager |
RunicBridgeFrontendBuildCommand |
RunicApplicationFrontendBuildCommand |
RunicBridgeFrontendInstallCommand |
RunicApplicationFrontendInstallCommand |
RunicBridgeInstallFrontend |
RunicApplicationFrontendInstallEnabled |
RunicBridgeBuildFrontend |
RunicApplicationFrontendBuildEnabled |
RunicBridgeCopyFrontend |
RunicApplicationFrontendCopyEnabled |
RunicBridgeBuildEnabled |
RunicApplicationFrontendGenerationEnabled |
RunicBridgeCompositionType |
RunicApplicationFrontendCompositionType |
RunicBridgeModelAssembly |
RunicApplicationFrontendModelAssembly |
RunicBridgeReactiveUiFlavor |
RunicApplicationFrontendReactiveUiFlavor |
RunicBridgeDocumentation |
RunicApplicationFrontendDocumentationEnabled |
RunicBridgeBootstrap (set in the bootstrap pass) |
RunicApplicationFrontendBootstrap; the old name is still set in this preview |
RunicViewsWindowProject |
RunicApplicationFrontendWindowProject |
RunicBridgeFrontendInput items |
RunicApplicationFrontendInput items |
RunicViewOutlet (@runic-artifex/angular) |
ViewOutlet, as in React, Vue and Svelte; the element stays <runic-view-outlet> |
Runic Desktop's window lifecycle is asynchronous too: see the
Runic.Desktop guide
for DesktopWindow.WaitForCloseAsync and signed window positions.
The First Window, CommunityToolkit Notes, and Reactive Notes examples exercise the packaged graph and generated client.
Threading: state after await
A Window's ViewModels belong to its model context. The bridge reads state, applies property writes and starts commands in that context's turns, one at a time. The rule for both MVVM flavours is:
Set ViewModel state directly in a command, also after
await. Leave the model context only for work that doesn't touch ViewModel state, and commit its result withIRunicModelContext.InvokeAsync.
[RelayCommand] // or ReactiveCommand.CreateFromTask(SaveAsync)
private async Task SaveAsync(CancellationToken token)
{
Status = "Saving";
await _store.SaveAsync(Title, token);
Status = "Saved"; // runs in a model-context turn
}
- A command body that the bridge starts, such as a CommunityToolkit
[RelayCommand]orAsyncRelayCommand, or a ReactiveUIReactiveCommand.CreateFromTask, runs in a turn. Everyawaitin it, nested ones included, resumes in a later turn of the same context, as code on a UI thread resumes on that thread. Writes afterawaitare applied in the order in which the continuations become ready, and no other turn runs between two awaits of one body. - Code after
ConfigureAwait(false), inTask.Run, in a timer or in another background callback runs outside the context. Do CPU-bound or blocking work there, then commit:await modelContext.InvokeAsync(() => Items = loaded). Don't useConfigureAwait(false)in ViewModel code that writes state after it. Code you start outside a turn, such as a test that callsExecuteAsyncdirectly, also commits withInvokeAsync. - Don't block a turn on asynchronous work (
.Result,.Wait(),GetAwaiter().GetResult()): its continuation needs the same context, so it deadlocks, as it would on a UI thread. - When the context closes, for example because the window closed, pending continuations run on the thread pool outside any turn, so the command still finishes; nothing presents its later writes.
- With WPF,
DispatcherModelContextis the UI dispatcher, so the same rule holds there. Navigation hooks are not commands; see Runic.Navigation threading.
Read-only and settable state
The web frontend is the View, so the usual MVVM rule applies: the ViewModel's
C# accessibility is the contract, and the View decides what it binds two-way.
A property with a public getter is state every View can read. A property with a
public setter is also what any View, whether WPF, Avalonia or the web frontend,
may set, so its generated client gets a set<Property> member. A private set
keeps the property read-only to every View. Runic adds no attribute of its own.
Give status that the ViewModel computes, such as IsDirty, Error, a status
message or a collection, a private setter, and keep form fields publicly
settable. With CommunityToolkit.Mvvm 8.4, declare partial properties:
public sealed partial class EditorViewModel : ObservableObject
{
// Form field: the client has setTitle.
[ObservableProperty]
public partial string Title { get; set; }
// Status: read-only to every View, so there is no setIsDirty or setError.
[ObservableProperty]
public partial bool IsDirty { get; private set; }
[ObservableProperty]
public partial string? Error { get; private set; }
}
The examples in this README use partial properties. [ObservableProperty]
on a field, such as [ObservableProperty] private bool isDirty;, is still
supported, but the property it generates always has a public setter, so the
web frontend could set it too. A field suits a form field, such as the First
Window example's step. For status, use a partial property with a private
setter, or write the property out with SetProperty and a private setter. With
ReactiveUI, give the property a private setter, either written out with
RaiseAndSetIfChanged or generated with [Reactive]:
public sealed partial class EditorViewModel : ReactiveObject
{
private bool _isDirty;
// Form field: the client has setTitle.
[Reactive]
public partial string Title { get; set; } = "";
public bool IsDirty
{
get => _isDirty;
private set => this.RaiseAndSetIfChanged(ref _isDirty, value);
}
// The same with the ReactiveUI source generator.
[Reactive]
public partial string? Error { get; private set; }
}
The comment on each generated set<Property> member names the C# setter it
calls. To ask the ViewModel to change its status, call a command. The
React,
Vue,
Svelte
and Angular
guides show reading state one way and binding a settable property two way.
The dotnet new runic-app template's Counter page has one of each.
Generated TypeScript
Each ViewModel gets a module named after it (EditorViewModel becomes
editor.ts) with its state interface, client interface and connect
function. Every enum, DTO and union in the contract becomes one exported type
named after its C# type, declared once in types.ts beside the modules. Next
to each client, editor.mock.ts exports a typed test double, mockEditor; see
Testing.
Applications import it only from tests, so it is not bundled. Each
module imports the named types it uses and re-exports them, so
import type { DocumentPane } from "./generated/document.js" works too:
/// <summary>A pane of the document page.</summary>
public enum DocumentPane { Editor, Preview }
/// <summary>The pane the document currently shows.</summary>
public DocumentPane ActivePane => ...;
/** A pane of the document page. */
export type DocumentPane = "Editor" | "Preview";
- Every C# type has one TypeScript form; Wire types lists them.
Numbers follow JavaScript's precision, so
longis abigint. - An enum is a union of its wire names (the C# names, or
[RunicAlias]), so aswitchover it can be exhaustive. - A DTO is an interface with its wire member names. A
[RunicUnion]is a union of its cases, discriminated by$case. - Two C# types with the same name, or a name that collides with a generated
declaration such as
EditorState, are qualified with their namespace segments, for exampleNotesItemandTasksItem. Names are stable while no new collision appears: adding a same-named type, even in another ViewModel, qualifies the existing one too. Import the name your frontend uses and expect a compile error, not a silent change, when that happens. Generic DTOs append their arguments, for examplePageOfNoteRoworBoxOfArrayOfInt32, and an enum without cases isnever. A C# type named like a TypeScript reserved word is qualified the same way. - A generic DTO member declared
Tis nullable where the type argument is:value: stringinBox<string>andvalue: string | nullinBox<string?>, and the generated .NET reader rejectsnullfor the former. A member declaredT?is always nullable. Members inherited from a generic base class follow the derived type's arguments, and a type without annotations, such as[RunicCommandInput(typeof(Box<string>))]or a union case, is non-nullable throughout. Uses whose members differ get separate declarations, for exampleBoxOfStringandBoxOfNullableOfString; a type used only asBox<string?>keeps the plain nameBoxOfString. - A content property is the union of the
PageReferencetypes of the ViewModels it can present, such asDocumentPageReference | HomePageReference, with| nullwhen it is nullable. ANavigationRegion<TContent>slot always includes| null, because a region can be empty; see Navigation. The frameworks'ViewRegistry<State["main"]>accepts such a nullable type. <summary>comments on ViewModels, state properties, commands, interactions, DTO types and members, and enum cases become TSDoc. A summary on a partial property such as[ObservableProperty] public partial bool IsDirty { get; private set; }or[Reactive] public partial string Name { get; set; }is read from its declaring part in source, found through the assembly's PDB (source paths that a CI or deterministic build maps, such as/_/, resolve through the project's source roots), because the XML documentation file keeps the generated part's<inheritdoc/>. The bootstrap pass writes the XML documentation file for this; a project that already writes one keeps its own. SetRunicApplicationFrontendDocumentationEnabled=falseto skip it. A separate model assembly (RunicApplicationFrontendModelAssembly) needs its XML documentation file beside it. A command generated from a method, such as a CommunityToolkit[RelayCommand], uses the method's comment. The C#-only comments an MVVM library's source generator writes are not copied; a generated cancel command is documented as cancelling its command.- A command argument keeps the parameter name of its CommunityToolkit
[RelayCommand]method. Other commands name it after the argument's C# type, orinputfor scalars.
The generated C# bridge calls public runtime types such as ViewModelBridge<T>,
PropertyDescriptor<T>, CommandDescriptor<T>, BridgeWire, BridgeJson and
ReactiveCommandExecution. They are marked [EditorBrowsable(Never)], so they
stay out of completion lists; they are not application API.
Wire types
State, DTO members, command arguments and results use these TypeScript types in the generated client. The client checks every value it receives against them, so a malformed reply never reaches application state.
| C# | TypeScript | On the wire |
|---|---|---|
bool |
boolean |
|
int, uint, short, ushort, byte, sbyte |
number |
A number; the client rejects one outside the C# type's range. |
double, float |
number |
A finite number. |
long, ulong, BigInteger |
bigint |
A decimal string, such as "9007199254740993". |
decimal |
string |
Invariant decimal text, such as "12.50", so no digit is lost. |
string |
string |
|
Guid |
string |
"d3b07384-d9a0-4c9b-8f1e-2a6c0e5f7b11", in lower case. |
DateTimeOffset |
string |
ISO 8601 with its offset: "2026-10-03T12:30:00.0000000+02:00". The browser's new Date().toISOString() is accepted. |
DateTime |
string |
ISO 8601 round-trip text: Z for UTC, an offset for local time, none for an unspecified kind. A value ending in Z reads back as UTC. |
DateOnly |
string |
"2026-10-03" |
TimeOnly |
string |
"08:15:00.0000000"; "08:15:00" is accepted. |
TimeSpan |
string |
.NET's invariant c format, [-][d.]hh:mm:ss[.fffffff], such as "1.02:03:04". |
| An enum | A union of its wire names, such as "Editor" \| "Preview" |
The C# name, or its [RunicAlias]. A [Flags] enum needs a [RunicBridgeCodec]. |
T?, for a value or a reference type |
T \| null |
null. In a value that reaches the page the member is always present; see Optional members for values the page sends. |
T with nullable reference types enabled |
T |
Never null. Without annotations (#nullable disable), a reference type is T too. |
T[], List<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IList<T>, ICollection<T>, IEnumerable<T>, ImmutableArray<T>, ImmutableList<T>, ObservableCollection<T>, ReadOnlyObservableCollection<T>, ReadOnlyCollection<T> |
readonly T[] |
An array. string?[] is readonly (string \| null)[]. Multi-dimensional arrays and other collections fail the build. |
Dictionary<string, T>, IDictionary<string, T>, IReadOnlyDictionary<string, T> |
Readonly<Record<string, T>> |
An object. Other key types fail the build. |
| A record, class or struct (DTO) | An interface with readonly members |
A member's name starts with a lower-case letter (MeasuredAt is measuredAt), unless [RunicAlias] or [JsonPropertyName] names it. |
A [RunicUnion] |
A union discriminated by $case |
|
A type with [RunicBridgeCodec] |
The codec's TypeScript type | Whatever the codec writes. |
JsonElement, JsonElement?, JsonNode? |
unknown |
Any JSON value, as it is. A JsonElement holds JSON null as a value; a default JsonElement, which holds none, is written as null. JsonNode? and JsonElement? are C# null for JSON null. |
JsonNode |
NonNullable<unknown> |
Any JSON value except null, which a JsonNode cannot hold. JsonObject, JsonArray, JsonValue and JsonDocument fail the build: declare a JsonNode, or a DTO for a known shape. |
Other framework types, such as object, char, Uri and tuples, fail the
build with a RUNICBRIDGE diagnostic that names the member.
A number is exact only up to 2^53 (Number.MAX_SAFE_INTEGER). A long can
be larger, and an id or a version above that would silently change if it were
a number, so long, ulong and BigInteger are bigint. The generated
TSDoc of such a member says so. A bigint does not mix with number in
arithmetic or Math, and JSON.stringify rejects it, so a formatter written
for numbers needs Number(value) first; BigInt(value) converts back.
For a value the UI formats, such as a byte count or a duration in
milliseconds, prefer double in C#: it is a number in TypeScript and exact
for every whole number up to 2^53, which is 8 PiB as a byte count. Keep long
for ids and counters that must stay exact. Use int for a small count.
The client checks a JSON value in both directions: it must be null, a
boolean, a finite number, a string, or an array or plain object of them, at
most 64 levels deep. undefined (except as an object member, which
JSON.stringify leaves out), NaN, a bigint, a Date or a Map is
rejected before the call rather than changed by JSON.stringify. The value is
not copied. Its numbers are JavaScript numbers in the page, exact only up to
2^53, while .NET keeps the digits it reads, such as 2.50.
A frontend whose own types use optional members (version?: string) maps the
T | null members at its edge, for example with value ?? undefined.
Optional members
A DTO that only the page sends, as a command input or an interaction result,
declares as optional (label?: string | null) the members .NET fills in when
the page leaves them out:
- a nullable member, which is
nullwhen it is missing; - a constructor parameter with a default, which takes that default;
- a member with a setter or
initthat is nullable, which keeps the value a new instance has, such as its initializer.
public sealed record BuildRequest(string Action, string? Host = null, int Retention = 3);
await builds.start({ action: "switch" }); // host null, retention 3
A non-nullable member without a constructor default stays required: .NET cannot tell whether it has an initializer. A type that also reaches the page, as state, a command result, a declared failure or an interaction input, declares every member as present, because .NET always writes it; .NET still fills in what a page leaves out.
A JsonElement member tells an absent value from null. A default
JsonElement, which holds no value, is left out of the object, and the member
is optional (previous?: unknown) everywhere: undefined in the page is
absent, null is JSON null. A missing member reads as a default
JsonElement. Elsewhere, such as a state property or a command argument of
its own, a default JsonElement is null.
A DTO that only reaches the page needs no constructor for its members: a
class with only read-only or computed properties is fine as state or a result. A DTO the
page sends needs a public constructor whose parameters map to its properties,
or a public parameterless constructor with public setters (RUNICBRIDGE003).
Generator diagnostics
The generator reports every ViewModel's first problem in one build. An error
names the type and member, and points at its source line when the inspected
assembly has a portable PDB (the default). A member generated by a source
generator, such as a CommunityToolkit [ObservableProperty], points at its
[RelayCommand] method or its declaring class. Each error and warning ends with
a link to its entry in the diagnostics catalog
for the release you use.
RUNICBRIDGE013 is a warning and repeats on builds that reuse the cached
generation; every other code fails the build.
The build targets run the generator and the frontend install and build
commands without wrapping their failures in MSB3073. Errors and warnings that a
command prints in the canonical file(line,column): error CODE: text form, such
as the generator's, tsc's and vue-tsc's, are logged once with their code and location.
When svelte-check fails, its errors and warnings are logged at their file, line and
column too, without a code, from its default output or --output machine; warnings
of a check that passes stay in the command output. A
failed command that reports no such error gets one summary error instead:
RUNICBRIDGE001 for the generator, RUNICBRIDGE016 for the frontend
commands.
| ID | Problem | Fix |
|---|---|---|
RUNICBRIDGE001 |
Invalid generator invocation or build configuration, such as a malformed RunicApplicationFrontendCompositionType; also an internal generator error. |
Correct the build property. Report an internal error with the ViewModel that triggers it. |
RUNICBRIDGE002 |
A CommunityToolkit ObservableValidator ViewModel in a Native AOT publish. |
Publish framework-dependent until its validation is verified under AOT. |
RUNICBRIDGE003 |
A state, command, or interaction value type is not a supported bridge value. The message names the member path, for example EditorViewModel.Current.value. |
Use a supported scalar, collection, public DTO, [RunicUnion] or [RunicBridgeCodec] type. Exclude computed DTO properties with [RunicIgnore], or opt into unconditional [JsonIgnore] and [JsonExtensionData] as described under Shared DTO exclusions. A NavigationResult<T> holds .NET content: return its Outcome, a NavigationOutcome, as CreateBackCommand() does. |
RUNICBRIDGE004 |
Two generated names collide: ViewModel names, presentation kinds, state wire names, the reserved revision and validation fields, interactions, routes, client members, or generated files (including a hand-written types.ts), or a type named like a generated bridge (CounterBridge beside CounterViewModel). Also a command whose client member is reserved in a TypeScript object: new (a construct signature), then (a thenable client), constructor, __proto__, toJSON, toString or valueOf. |
Rename one member, or set a state wire name with [RunicAlias]. Rename a reserved command; the message suggests a name, such as CreateCommand for NewCommand. |
RUNICBRIDGE005 |
The model assembly or one of its dependencies could not be loaded, a Runic View could not be loaded, or a member the Bridge exposes has a type from an assembly the generator cannot load, such as a WPF type. The message names the member path. | Mark a WPF-typed member [RunicIgnore]; otherwise check the bootstrap output and package versions. |
RUNICBRIDGE006 |
The assembly has no Window or View class (an error only when generation is required, see Build properties), one is not public, top-level, concrete and closed, or a View contract is invalid, duplicated or missing. | Make the class public and top-level; give each [RunicViewContract] a unique letters-and-digits name. |
RUNICBRIDGE007 |
A ViewModel does not implement INotifyPropertyChanged, is not a public top-level class, or has no state, command or interaction. |
Change the ViewModel declaration. |
RUNICBRIDGE008 |
A state property has no public getter or an empty wire name, ViewModel content or a NavigationRegion<TContent> slot has a public setter, a ViewModel collection's item type has no registered View, or a region slot is a NavigationRegion<object> or has no ViewModel with a registered View that is a TContent. |
Add a getter, make content read-only to the web view, register a View for the item or content type, or give the region a specific content interface or base class. |
RUNICBRIDGE009 |
A command's name does not end with Command, its shape is unsupported, or a non-ReactiveUI command has [RunicCommandResult]. |
Rename the command or use a supported CommunityToolkit, ReactiveUI or [RunicCommandInput] command. |
RUNICBRIDGE008, append-only text |
A [RunicAppendOnlyText] member is not a read-only string or string? state property. |
Remove its public setter, or the attribute. |
RUNICBRIDGE010 |
A [RunicCollection] member is not a read-only, non-nullable collection of DTO rows, or its key is not a non-nullable string, Guid or Int32 row property. |
Change the collection or its key. |
RUNICBRIDGE011 |
A ReactiveUI interaction has no public getter or has a public setter. | Expose the interaction as a get-only property. |
RUNICBRIDGE012 |
A [RunicFailure] is on a member that is not a Bridge command or its [RelayCommand] method, is on both the property and the method, or names object, an exception or Nullable<T>. |
Declare one failure type per command: any Bridge value type, typically a DTO, an enum or a [RunicUnion]. A failure type the Bridge cannot encode is RUNICBRIDGE003 at {Model}.{Command}.failure. |
RUNICBRIDGE013 (warning) |
A public, concrete ViewModel in the application assembly can be put in a ViewModel content property, ViewModel collection or NavigationRegion<TContent> slot, but has no View, so pushing or presenting it throws NotSupportedException naming the slot and the type. Broad slot types are not checked: framework types (System, ReactiveUI, CommunityToolkit), a type the slot's owner or a Window's ViewModel also derives from or implements, and a type that more than eight ViewModels without a View could fill. |
Add a View, such as public sealed partial class SettingsView : RunicView<SettingsViewModel>; (with the slot's [RunicViewContract], if any). If the type is never presented there, suppress the warning with <NoWarn>$(NoWarn);RUNICBRIDGE013</NoWarn>. |
RUNICBRIDGE014 |
A project declares Runic Views, but RunicApplicationFrontendDirectory does not exist or, with the default build command, has no package.json. Reported by the build targets. |
Create the frontend there, point RunicApplicationFrontendDirectory at your frontend package, or set RunicApplicationFrontendBuildEnabled=false when another tool builds it. |
RUNICBRIDGE015 (warning) |
A project declares a Runic Window or View, but generation is off because it references no host adapter and not Runic.Application.Views directly, so no bridge, AddRunicViews() or client is generated. Reported by the build targets. |
Set RunicApplicationFrontendGenerationEnabled=true to generate in that project, or false when another project generates its Views. |
RUNICBRIDGE016 |
The frontend install or build command failed without reporting an error in the canonical file(line,column): error CODE: text form or as a svelte-check error (a Vite or package-manager error, for example), or the install created no node_modules for declared dependencies. The command's own output is printed above the error. Reported by the build targets. |
Fix the problem the command reports, or run dotnet runic doctor to check the package manager and lock file. |
RUNICBRIDGE017 (warning) |
The project, an import or the command line sets a build property or item by its 0.7.0-preview.6 name (RunicBridge*, RunicViewsWindowProject). The old name still applies in this preview. Reported by the build targets. |
Rename it to the RunicApplicationFrontend* name in the message; see the rename table. |
RUNICBRIDGE020 (warning) |
Types of the application assembly were skipped because an assembly they need, other than a Windows Desktop one, could not be loaded. None of them is a Runic View. With optional generation and no Views this is RUNICBRIDGE005 and the generated output is kept. |
Reference the shared framework or package that contains the assembly, or make it available in the build output. |
Build properties
The build targets ship in this package and apply to every project that
references a host adapter. A project needs none of these properties unless it
departs from the conventional Frontend folder. Every property except
RunicApplicationHost starts with RunicApplicationFrontend, as do the
development-server properties of dotnet runic dev. The RunicBridge* and RunicViewsWindowProject names of
0.7.0-preview.6 and earlier still apply in this preview with warning
RUNICBRIDGE017; the rename table
maps them. Underscore-prefixed properties, RunicApplicationHostAdapter (which
the host adapters set) and the RunicViews* target names are not settings.
| Property | Default | Purpose |
|---|---|---|
RunicApplicationFrontendDirectory |
$(MSBuildProjectDirectory)/Frontend |
Frontend package directory. |
RunicApplicationFrontendGeneratedDirectory |
<frontend>/src/generated |
Generated TypeScript clients. |
RunicApplicationFrontendPackageManager |
packageManager in package.json, then pnpm-lock.yaml, bun.lock, else npm |
Selects the default build and install commands. |
RunicApplicationFrontendBuildCommand |
npm run build, pnpm run build, or bun run --bun build |
Production frontend build. |
RunicApplicationFrontendInstallCommand |
npm ci, pnpm install --frozen-lockfile, or bun install --frozen-lockfile, each with --ignore-scripts |
Runs before the frontend build when package.json or a lock file changed since the last install (stamp: obj/runic-bridge-frontend-install.stamp), or when declared dependencies have no node_modules. A package.json without dependencies needs no node_modules. |
RunicApplicationFrontendInstallEnabled |
true |
Set false when a workspace install owns the frontend packages. |
RunicApplicationFrontendNodeModulesDirectory |
none | A node_modules installed by something else, such as the Nix builder from the lock file. The install does not run, and the frontend's node_modules becomes a symbolic link (a junction on Windows) to it; an existing node_modules that is not a link is RUNICBRIDGE021. |
RunicApplicationFrontendBuildEnabled |
true |
Set false when another tool owns the frontend build. TypeScript is still generated. |
RunicApplicationFrontendCopyEnabled |
true |
Mirrors <frontend>/dist to www/ in the build output: a file that an earlier copy wrote and dist no longer has is deleted. Publish always mirrors it. |
RunicApplicationHost |
the only host adapter referenced | desktop or cswebui: whose Views client the build copies to www/runic-views.js. Set it when a project references both hosts. The frontend build receives it as RUNIC_APPLICATION_HOST. |
RunicApplicationFrontendCompositionType |
<project name>.RunicBridgeComposition (host adapters) |
Generated composition class. |
RunicApplicationFrontendModelAssembly |
the project itself | Inspect a separately built ViewModel assembly instead of a bootstrap build. |
RunicApplicationFrontendReactiveUiFlavor |
none | primitives or reactive for ReactiveUI projects. |
RunicApplicationFrontendGenerationEnabled |
see below | true requires Bridge generation, false turns it off. |
RunicApplicationFrontendDocumentationEnabled |
true |
false skips the XML documentation file that the bootstrap pass writes for comments in the generated TypeScript. |
RunicApplicationFrontendWindowProject |
false |
true marks a Window project that dotnet runic dev and doctor run. |
dotnet build and dotnet publish copy the built frontend as loose files to
www/ next to the executable; they do not embed it. On macOS, an application
in an .app bundle without www/ next to its executable reads the frontend
from the bundle's Contents/Resources/www, where Apple expects resources
(RunicWindowOptions.ContentRoot overrides both). dotnet runic dev sets
RunicApplicationFrontendBuildEnabled=false and
RunicApplicationFrontendCopyEnabled=false while its development server serves
the frontend. The
dotnet runic README
lists the development-server properties it reads.
Two items extend the frontend build. RunicApplicationFrontendInput adds an
input outside the frontend directory, such as a monorepo's shared components
that the frontend imports through a Vite alias; a change rebuilds the frontend:
<ItemGroup>
<RunicApplicationFrontendInput Include="../ui/lib/**/*;../src/locales/**/*" />
</ItemGroup>
RunicApplicationFrontendBuildEnvironment names an environment variable that
changes the built frontend, such as a VITE_* variable set in the shell, in
addition to NODE_ENV, which is always tracked. .env files in the frontend
directory are inputs already (see Incremental generation):
<ItemGroup>
<RunicApplicationFrontendBuildEnvironment Include="APP_FLAVOR" />
</ItemGroup>
Bridge generation turns on by default for a project that references
Runic.Application.Views directly, references a host adapter
(Runic.Application.Views.Desktop, Runic.Application.Views.CsWebUi or
Runic.Application.Views.Wpf) directly or transitively, or builds in this repository.
Each adapter's build props set RunicApplicationHostAdapter to true, which is
what the targets check. A project that reaches Runic.Application.Views only through
another project, such as a library that references a model project, does not
generate; if it declares a Window or View anyway, the build warns with
RUNICBRIDGE015. A test project (IsTestProject is true, which
Microsoft.NET.Test.Sdk and TUnit set) that references a Runic application
uses the application's generated Bridges: when it declares no Window or View
of its own and has no frontend package.json, RunicApplicationFrontendInput
items or RunicApplicationFrontendModelAssembly, generation stays off there without a
diagnostic or a bootstrap build. Any other project keeps the diagnostics
below. That default is optional:
when the assembly declares no Runic Window or View, for example an application
that only uses the navigator, the build generates nothing (and removes stale
generated C# and TypeScript), skips the frontend install, build and copy steps,
and succeeds with a normal-importance message. A publish that reuses an earlier
build (--no-build) still copies an existing <frontend>/dist to www/.
Generation is required, and RUNICBRIDGE006 stays an error for an assembly
without a Window, when the project sets RunicApplicationFrontendGenerationEnabled to true, or
when it shows that it expects output: a package.json in
RunicApplicationFrontendDirectory, RunicApplicationFrontendInput items,
RunicApplicationFrontendCompositionType or RunicApplicationFrontendModelAssembly. Projects that
reference Runic.Application.Views.CsWebUi or Runic.Application.Views.Desktop, other than
those test projects, always require generation, because the adapter sets
RunicApplicationFrontendCompositionType. WPF projects (UseWPF) stay optional, because a
WPF application often keeps its Views in a WPF-free model project. A
navigator-only application should set
RunicApplicationFrontendGenerationEnabled to false: that also skips the nested bootstrap
build, which optional mode still pays for.
When the Views live in a referenced project, the application copies that
project's built <frontend>/dist to its own www/ on build and publish, so the
application itself can set RunicApplicationFrontendGenerationEnabled to false. The
application's own frontend wins over a referenced one with the same file. WPF's
temporary markup-compile project (*_wpftmp) never runs the generator; it
compiles the code that the real project generated.
A single-project application is compiled twice: a bootstrap pass with an empty
generated composition, which the generator inspects, then the real build with
the generated code. RunicApplicationFrontendBootstrap is true only in the bootstrap pass.
The host of a page
A page loads webui.js, which the host serves, and runic-views.js, the host's
Views client, which the host package copies into www/:
Runic.Application.Views.Desktop (and so Runic.Application.Views.Wpf) copies
runic-desktop-views.js, Runic.Application.Views.CsWebUi copies
runic-cswebui.js. Both are also copied under those names, so a page that loads
one of them directly keeps working. Neither index.html nor the Vite
configuration names a host, so the same dist/ runs on every host, and
switching hosts changes the host package and Program.cs, not the frontend.
runic({ desktop: true }) from @runic-artifex/vite-plugin-runic is not needed
for Views: it adds the Runic Desktop bootstrap script, which CS-WebUI does not
serve.
A project that references both hosts, for example to ship on CS-WebUI and
compare on Runic Desktop, chooses the client with RunicApplicationHost:
dotnet run -p:RunicApplicationHost=desktop
Without it, such a project copies no www/runic-views.js, and gets warning
RUNICBRIDGE019 when a copied page loads runic-views.js. A page that loads
runic-cswebui.js or runic-desktop-views.js itself builds without the
warning. The frontend build receives the host as the RUNIC_APPLICATION_HOST
environment variable and is part of its build state, so a Vite configuration
that still needs a host-specific build can read it and is rebuilt when it
changes. A frontend that a referenced project builds, such as the model project
of a WPF application, sees that project's host, which is empty when it
references no host package; such a frontend cannot depend on the application's
host.
Adding a Window to an existing web app
A Window's frontend can reuse the components, styles and translations of a web
app in the same repository, without copying them. The frontend stays a package
of its own; Vite, TypeScript, Tailwind and the .NET build each need to be told
about the files outside it. This layout puts a Svelte and Tailwind app in ui/
and the Runic project next to it:
my-app/
├── ui/ the existing web app
│ ├── lib/ components, formatters and t() ($lib)
│ ├── locales/ message tables
│ └── app.css the Tailwind theme
└── runic/
├── MyApp.Runic.csproj
└── Frontend/ the Window's frontend package
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/app.css
resolve.alias points the app's import paths at its files. An alias can also
replace a module that the page does not want, such as a global store that
owns the app's HTTP session, with a small stand-in; list it before the
general alias. resolve.dedupe resolves the shared files' bare imports from
this package. Without it, a file in ui/lib resolves svelte from ui/,
which gives the page a second Svelte runtime, or fails when ui/ has no
node_modules. List every package that both the shared files and the page
import, and add those packages to this frontend's package.json:
// runic/Frontend/vite.config.ts
import { resolve } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { runic } from "@runic-artifex/vite-plugin-runic";
import { defineConfig } from "vite";
const ui = resolve(import.meta.dirname, "../../ui");
export default defineConfig({
base: "./",
plugins: [tailwindcss(), svelte(), runic()],
resolve: {
alias: [
// The app's global store is not used here; its readers get a stand-in.
{ find: /^\$lib\/app\.svelte$/, replacement: resolve(import.meta.dirname, "src/app-shim.svelte.ts") },
{ find: /^\$lib/, replacement: resolve(ui, "lib") },
],
dedupe: ["svelte", "clsx", "tailwind-merge", "tailwind-variants", "@lucide/svelte"],
},
build: {
rolldownOptions: { input: ["index.html"] },
outDir: "dist",
emptyOutDir: true,
target: "es2022",
},
});
TypeScript needs the same paths, in the same order:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"$lib/app.svelte": ["./src/app-shim.svelte.ts"],
"$lib": ["../../ui/lib"],
"$lib/*": ["../../ui/lib/*"]
}
}
}
Tailwind generates only the classes it finds in the files it scans, and it
does not scan outside this package, so the classes that only the shared
components use would be missing. Import the app's theme and name the shared
files with @source:
/* runic/Frontend/src/app.css */
@import "../../../ui/app.css";
@source "../../../ui/lib";
The .NET build rebuilds the frontend when a file under its frontend directory
changes. Add the shared files as RunicApplicationFrontendInput items, so a
change there rebuilds the page too, and name any environment variable that the
shared code or the Vite configuration reads as a
RunicApplicationFrontendBuildEnvironment item; see
Build properties:
<ItemGroup>
<RunicApplicationFrontendInput Include="../ui/lib/**/*;../ui/app.css;../ui/locales/**/*" />
<RunicApplicationFrontendBuildEnvironment Include="VITE_DEFAULT_LOCALE" />
</ItemGroup>
- Translations. The page can import the app's
t()and its message tables through the alias, and take the locale fromnavigator.language, which usually follows the system language (LANGon Linux). Include the message tables inRunicApplicationFrontendInput, so an edited translation rebuilds the page. - Hosts. The page loads
runic-views.js, so it needs no host-specific script or Vite configuration; see The host of a page. - Type checking.
svelte-checkortscin the Runic frontend also checks the shared files it imports, without the app's own dependencies and type declarations, and can report errors in them that the app's own check does not. Check the shared files in the app that owns them. - Lock files. The frontend has its own
package.jsonand lock file unless it joins the app's package manager workspace. In a workspace, setRunicApplicationFrontendInstallEnabledtofalse, because the workspace install ownsnode_modules. - Lists. Tailwind removes list markers from every list, and Runic Desktop's WebKitGTK then no longer reports such a list as a list to a screen reader; see Runic.Desktop accessibility.
- Screen readers on Linux. On Runic Desktop's WebKitGTK, live regions and changed accessible names are silent under Orca, and a list item whose only content is a button can crash the page; see the same section for what to use instead.
Typed commands and checked writes
CommunityToolkit IRelayCommand<T> and IAsyncRelayCommand<T> use the same
supported input shapes and generated codecs as ReactiveUI commands: scalars,
nullable values, DTOs, collections, unions, and explicit custom codecs. Generated
canX(input) queries use the actual parameter. Async Toolkit commands expose
startX, completion, recovery, and cancellation. A [RelayCommand] method that
returns Task<T> delivers its T as the operation's result; one that returns
Task has none.
Toolkit cancellation calls the command's Cancel() and therefore targets its
current execution; it does not isolate concurrent invocations of the same
command instance.
[RelayCommand(IncludeCancelCommand = true)] is supported too. Toolkit's
generated SaveCancelCommand exposes parameterless saveCancel() and
canSaveCancel state in the web client. It executes the actual generated cancel
command, so availability follows Toolkit's CanBeCanceled and cancellation
targets the same native SaveCommand. The existing startSave operation still
owns admission, completion, recovery, and View lifetime cancellation. Recognition
requires Toolkit's generator metadata, the paired async command, and its
cancellation-token method with IncludeCancelCommand = true; other plain
ICommand properties still require [RunicCommandInput].
A command without an argument publishes its availability as canX state. When
[RelayCommand(CanExecute = nameof(CanCreate))] names a bool property whose
wire name is that state, such as CanCreate for CreateCommand, the property
is that availability and is written once, from the command's CanExecute.
A PropertyChanged for the property publishes it, so it needs no
[RunicIgnore], and the page needs no NotifyCanExecuteChangedFor. A property with another name, or one for a
command with an argument, whose availability is the canX(input) query, stays
state of its own.
Checked-write receipts decode their values just like state: for example, an
Int64 receipt's snapshot.value or conflict's incoming.value is a bigint.
An identical request ID and payload replays its receipt. Reusing an ID with a
different baseline version, baseline value, or new value produces a conflict.
Each field retains at most 64 receipts within a default 256 KiB encoded-value
and receipt-metadata budget. Evicted or oversized receipts leave bounded
request-ID tombstones; reconcile authoritative state before issuing a new ID.
The write may have succeeded even when its receipt could not be retained.
Operation results and parameterised queries
What a call gives the page back depends on the command:
| Generated member | Resolves to |
|---|---|
estimate(choice) |
The ViewModel's state after the command finished (Promise<CleanupState>), or a BridgeOutcome of that state and a declared failure. |
canEstimate(choice) |
Whether the command can run with that argument (Promise<boolean>). Generated for a command with a parameter, instead of a canEstimate state property. |
startEstimate(choice) |
A BridgeOperation<TResult> handle for status, Cancel and outcome(). |
A command's result reaches the page through its operation. A public
ReactiveCommand<TInput, TResult> generates BridgeOperation<TResult>, and
outcome() resolves to { ok: true, value }; see
Generated ReactiveUI contracts.
A CommunityToolkit [RelayCommand] method that returns Task<T> generates
BridgeOperation<T> the same way, with T's nullability:
[RelayCommand(IncludeCancelCommand = true)]
private async Task<CleanupEstimate> MeasureAsync(CleanupChoice choice, CancellationToken token) =>
new(choice.Retention, await estimator.FreedBytesAsync(choice.Retention, token), DateTimeOffset.Now);
const outcome = await cleanup.startMeasure({ retention: 3 }).outcome();
if (outcome.ok) render(outcome.value.bytes);
A method that returns Task, a synchronous command and a plain ICommand have
no result: their operation is a BridgeOperation<void>. The awaited measure()
call resolves to the state after the command either way.
A result that other parts of the page show, or that must survive a reload, is better published as state. Publish it in a read-only property, together with the input it answers. The page can then tell whether the result still matches its current choice. A second property tells where the work is, so the page can show progress, cancellation and failure. This is a simplified version of a cleanup estimate that measures in .NET what keeping the newest restore points would free:
/// <summary>What to measure.</summary>
public sealed record CleanupChoice(int Retention);
/// <summary>What a cleanup keeping the newest <c>Retention</c> restore points frees.</summary>
public sealed record CleanupEstimate(int Retention, double Bytes, DateTimeOffset MeasuredAt);
public enum EstimatePhase { Idle, Measuring, Measured, Cancelled, Failed }
public sealed partial class CleanupViewModel(ICleanupEstimator estimator) : ObservableObject
{
/// <summary>The last measurement, with the choice it answers.</summary>
[ObservableProperty]
public partial CleanupEstimate? Estimate { get; private set; }
[ObservableProperty]
public partial EstimatePhase Phase { get; private set; }
[ObservableProperty]
public partial string? Error { get; private set; }
/// <summary>Measures what a cleanup keeping <paramref name="choice"/> frees.</summary>
[RelayCommand(IncludeCancelCommand = true)]
private async Task EstimateAsync(CleanupChoice choice, CancellationToken token)
{
Phase = EstimatePhase.Measuring;
Error = null;
try
{
var bytes = await estimator.FreedBytesAsync(choice.Retention, token);
// The result is state, published with the input it answers.
Estimate = new CleanupEstimate(choice.Retention, bytes, DateTimeOffset.Now);
Phase = EstimatePhase.Measured;
}
catch (OperationCanceledException)
{
Phase = EstimatePhase.Cancelled;
throw;
}
catch (Exception e)
{
Phase = EstimatePhase.Failed;
Error = e.Message;
throw;
}
}
}
The page starts each measurement with startEstimate, through
createLatestOperationController from @runic-artifex/views, so a newer choice
cancels the measurement it supersedes. It shows an estimate only when the
estimate answers the current choice:
import { createLatestOperationController } from "@runic-artifex/views";
import { connectCleanup } from "./generated/cleanup.js";
const cleanup = await connectCleanup();
const measuring = createLatestOperationController();
let retention = 3;
function choose(keep: number) {
retention = keep;
return measuring.run({ start: () => cleanup.startEstimate({ retention: keep }) });
}
cleanup.subscribe(state => {
// An estimate answers the choice it was measured for, so ignore an older one.
const estimate = state.estimate?.retention === retention ? state.estimate : undefined;
if (state.phase === "Measuring") render("Measuring…");
else if (state.phase === "Failed") render(`Measuring failed: ${state.error}`);
else if (estimate) render(`Frees about ${formatBytes(estimate.bytes)}.`);
});
A question that takes parameters works the same way. There is no query route
that returns a computed value: run a command with the parameters, and read the
answer from the state it publishes. await cleanup.estimate({ retention: 5 })
resolves with the state after the command, so (await cleanup.estimate(...)).estimate
is the answer for a caller that awaits it. Only availability is a query:
canEstimate(choice) asks the command's CanExecute with that argument, and
nothing pushes a change of its answer. An answer that the page can compute
from state it already has, such as how many restore points a retention
removes, needs no round trip. For progress, Cancel and stale-selection
handling, see the
operations guide
and @runic-artifex/views.
Bytes is a double because a long would be a bigint in TypeScript; see
Wire types.
Batch synchronous model updates
using (BridgeSnapshotBatch.Begin(document))
{
document.Title = imported.Title;
document.Content = imported.Content;
document.Language = imported.Language;
}
Nested scopes on the same model capture and publish one final snapshot per attached bridge when the outer scope ends. Notifications still advance the revision, and direct command/checked-write replies capture current state immediately. A batch controls snapshot work; it does not make mutations atomic or own the model scheduler. Enter the model's execution context as usual and keep the batch around synchronous changes, after awaiting I/O. The translations editor uses this during bulk document reconciliation and command result updates.
Live logs and other growing text
A read-only string that grows at its end, such as the output of a running
build, would otherwise cross whole in every state, up to its full length per
line of output. Mark it [RunicAppendOnlyText], and a change of the property
alone crosses as the text it appends:
public sealed partial class OperationsViewModel : ObservableObject
{
private const int LogCharacters = 128_000;
/// <summary>The end of the running operation's log.</summary>
[ObservableProperty, RunicAppendOnlyText]
public partial string Log { get; private set; } = "";
private void OnOutput(string text)
{
var log = Log + text;
// Keeps the last 128 KB; the page receives the dropped length and the new text.
Log = log.Length > LogCharacters ? log[^LogCharacters..] : log;
}
}
The page sees an ordinary log: string. The Bridge compares the new value with
the value the page has and publishes a frame with the number of UTF-16 code
units dropped from its start and the text appended, keeping the longest text
the two share. Appends in one BridgeSnapshotBatch are one change. A cleared
or replaced log is a frame that drops everything. null (for string?), a
change together with other state, a ViewModel with validation, and a page that
missed a frame get full states, as with [RunicCollection]. The property must
be a read-only string or string? state property (RUNICBRIDGE008). The
frame format is in
collection delta frames.
Structured validation
Models and nested DTOs may implement INotifyDataErrorInfo. Generated snapshots
then expose validation.hasErrors, validation.truncated, and validation.errors, alongside existing
property error string arrays. Each error contains a path of wire property
names, list indexes, or dictionary keys and a message. Root entity errors use
an empty path. Aliases are preserved, so a postal-code error might have the path
["profile", "postal-code"].
Return a BridgeValidationMessage from GetErrors to supply a stable Code,
Severity, or relative CLR MemberPaths. Ordinary strings and
ValidationResult remain supported. Generated metadata resolves paths without
runtime reflection. Traversal and messages are bounded; truncated reports when
validation details are incomplete. Nested ErrorsChanged notifications publish new validation
state, and removed objects lose their subscriptions. This projection works
across model frameworks; it does not run validation rules itself.
Use [RunicIgnore] on validation-only DTO properties such as a computed
HasErrors; their values are represented by the validation projection.
Shared DTO exclusions
Public readable DTO properties are part of the Bridge contract, including
computed properties. A DTO the page sends needs a public constructor matching its
included properties, or public setters with a public parameterless constructor;
one that only reaches the page needs neither (Optional members).
Use [RunicIgnore] to leave a property on .NET without exposing it to the Bridge.
By default, [JsonIgnore] affects System.Text.Json serialization only; it does
not exclude a Bridge property.
For shared DTOs that already use System.Text.Json attributes, opt in from the assembly declaring the ViewModel:
using Runic.Application.Views;
[assembly: RunicBridgeJsonIgnore]
For example, a separate Core assembly can keep its DTO free of Runic dependencies:
using System.Text.Json.Serialization;
public sealed record HistoryQuery(string Message, string Author)
{
[JsonIgnore]
public bool IsFiltered => Message.Length != 0 || Author.Length != 0;
}
The opt-in excludes [JsonIgnore], [JsonIgnore(Condition = JsonIgnoreCondition.Always)]
and [JsonExtensionData] throughout that ViewModel's DTO graphs, including nested DTOs, collections,
command values, interactions and declared failures. Generated codecs, TypeScript,
validation traversal and the Hot Reload fingerprint use the same exclusions.
The Core assembly and the assembly declaring only a Window need no marker.
Each ViewModel uses its own declaring assembly's policy.
An extension-data member keeps the JSON members a DTO's type does not declare, such as those a newer version of the application saved, for System.Text.Json to write back. They stay on .NET: a DTO that the Bridge reads from the browser has no extension data.
public sealed record SavedRequest(string Kind)
{
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; set; }
}
Never, WhenWritingNull and WhenWritingDefault remain included; Bridge
fields have a fixed contract and are emitted even when null or default.
[RunicIgnore] always excludes a property, including one also marked
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]. Root ViewModel properties
still use [RunicIgnore]; this opt-in applies to DTO properties only.
Adding the marker can change an existing DTO wire contract and requires rebuilding
the .NET application and generated frontend together.
Failure detail in development
A command, setter, checked write or operation that throws replies with a
bounded message such as "Save failed." and is logged (see
Logging and telemetry). In
development the error also carries detail: {type, message, stack}, which the
TypeScript runtime exposes as BridgeError.detail and the Vite plugin shows in
DevTools. Development means DOTNET_ENVIRONMENT (or else
ASPNETCORE_ENVIRONMENT) is Development, which dotnet runic dev sets.
Set BridgeDiagnostics.IncludeFailureDetail to true or false to choose
explicitly. Detail can contain file paths and application data, so do not
enable it for a distributed build.
Declared failures
A command declares its expected failure type with [RunicFailure(typeof(SaveFailure))]
on the command property or on its CommunityToolkit [RelayCommand] method, and
signals it by throwing RunicFailureException(new TitleRequired()). The Bridge
replies domain-failed with the encoded failure, in every environment and
never with detail; an operation ends with the domain-failed status. Use
[RunicUnion] for several cases. A value that is not the declared type, one
that cannot be encoded, or one over 4 KiB is reported as an ordinary failure
(event 1008). A synchronous command reports only failures thrown synchronously
from Execute, so an async void plain ICommand cannot declare a failure
that it throws after its first await. Operations exist only for asynchronous
CommunityToolkit and ReactiveUI commands.
[RunicUnion(typeof(TitleRequired), typeof(TitleTaken))]
public abstract record SaveFailure;
[RunicUnionCase("titleRequired")] public sealed record TitleRequired : SaveFailure;
[RunicUnionCase("titleTaken")] public sealed record TitleTaken(string ExistingTitle) : SaveFailure;
[RelayCommand, RunicFailure(typeof(SaveFailure))]
private async Task SaveAsync(CancellationToken token)
{
if (string.IsNullOrWhiteSpace(Title)) throw new RunicFailureException(new TitleRequired());
// ...
}
The generated client's save() then returns
Promise<BridgeOutcome<EditorState, SaveFailure>> instead of
Promise<EditorState>, its operation is BridgeOperation<void, SaveFailure>, and
the generated mock encodes the failure. A ReactiveUI command also reports the
failure on ThrownExceptions; the generated Bridge observes it there and does
not log it, so the ViewModel needs no subscription.
Navigation
Navigation comes from the
Runic.Navigation
package, which this package depends on. Its types, and the model context
types, are in the Runic.Navigation namespace. Its README covers regions,
transitions, ownership, results and disposal. Register it per window scope
with services.AddRunicNavigation(), and give the window's session the
navigator's model context; AddRunicNavigation() does that for windows opened
from the scope.
Content slots
Expose a region as a get-only NavigationRegion<TContent> property, and the
generator treats it as a content slot. The snapshot presents Current, or
clears the slot when the region is empty. The wire value and TypeScript type
are those of a nullable content property (for example DocumentPageReference | HomePageReference | null), so
moving a non-null content property to a region adds | null, and the frontend
must render the empty state. A ViewRegistry<State["main"]> accepts the nullable
slot type and checks its non-null kinds. TContent must be an interface or base class of
ViewModels with registered Views. A NavigationRegion<object> slot, or one
with a public setter, is RUNICBRIDGE008.
The generated Bridge observes the region's Current and binds the window
session to the navigator, so retiring owned content forgets its routes. A
navigator serves one window, and the session must use the navigator's model
context. Nothing is forwarded by hand, and ViewOutlets and test drivers
(View<T>(vm => vm.Main)) work unchanged. Going back presents the same
PageReference id with the retained model, and the outlet mounts a fresh View.
Browser reload, remount and disconnect never touch entries.
Stale-route window. A commit retires and forgets departing owned content
right away, while the frontend sees the new Current only with the next state
capture. Until then it may still hold the old reference. An invocation on that
reference is rejected like any forgotten route, and the outlet may briefly
show the old View or nothing. The next capture converges.
Navigation command results
A command that navigates returns the request's NavigationOutcome, as
CreateBackCommand() and the adapter's entry commands do, because the Bridge
cannot encode a NavigationResult<TContent> (RUNICBRIDGE003). The generated
client receives it as a typed result:
interface NavigationOutcome {
readonly kind: "Committed" | "Rejected" | "Superseded" | "Failed";
readonly rejection: NavigationRejection | null; // set for "Rejected"
readonly activation: "None" | "Completed" | "Failed" | "Cancelled"; // "None" unless "Committed"
}
activation reports how the
activation hooks
(INavigationActivated) of the entry the commit made current ended. A
"Failed" hook leaves the commit standing, so a page that went back or
navigated can show that the destination's data did not load. Like a "Failed"
kind, it carries no exception message, which can contain application data:
the hook's exception is logged as event 1076, and code that needs it awaits the
region's request in its own command and reads Committed.ActivationError.
Logging and telemetry
Registering logging is all an application needs: with services.AddLogging(...),
for example AddLogging(logging => logging.AddSerilog(...)), or the Generic
Host, Runic logs every runtime message through that ILoggerFactory, with the
categories and event IDs below. RunicDesktopHost.Run also passes it to the Desktop host when
DesktopHostOptions.LoggerFactory is not set, until Run returns, and
RunicViewHost takes it from its Services when it opens. A host message
after that, such as a browser profile that a minute later still cannot be
deleted, or one from a factory that was disposed meanwhile, goes to Trace. Without a registered factory, Runic falls back to
System.Diagnostics.Trace.
OpenWindowAsync passes the scope's
ILoggerFactory to the Window's WindowContentSession when one is registered,
for example with services.AddLogging(...). A session constructed directly
takes it as loggerFactory. Without a factory, each entry is written to
System.Diagnostics.Trace (TraceError, or TraceWarning for warnings) as
its formatted message followed by the exception; a failed View mount goes to
standard error instead.
Every entry carries the exception, in every environment: logs belong to the
operator, whereas BridgeDiagnostics governs only what a Bridge reply shows
to web content. Exception messages can contain application data, so treat log
output accordingly. The message properties are listed per event.
Runic does not log an exception that ends the process, such as one thrown by
the application body that RunicDesktopHost.Run rethrows. The
crash reporting guide
shows how to log it from AppDomain.UnhandledException, capture a dump, keep
NativeAOT symbols and what to attach to a bug report.
| Event ID | Name | Level | Logged when | Properties |
|---|---|---|---|---|
| 1000 | BridgeCommandFailed |
Error | A command throws. | Model, Member, Route, ErrorType |
| 1001 | BridgeSetterFailed |
Error | A property setter throws. | Model, Member, Route, ErrorType |
| 1002 | BridgeFieldWriteFailed |
Error | A checked field write throws. | Model, Member, Route, ErrorType |
| 1003 | BridgeOperationAdmissionFailed |
Error | Admitting an operation throws. | Model, Member, Route, ErrorType |
| 1004 | BridgeOperationFailed |
Error | An admitted operation throws. | Member, ErrorType |
| 1005 | BridgeOperationCancellationCallbackFailed |
Warning | A cancellation callback throws while a Window closes. | ErrorType |
| 1006 | BridgeCommandDomainFailed |
Debug | A command throws its declared failure. | Model, Member, Route, FailureType |
| 1007 | BridgeOperationDomainFailed |
Debug | An admitted operation throws its declared failure. | Member, FailureType |
| 1008 | BridgeDomainFailureNotEncoded |
Error | A RunicFailureException from a command or operation is undeclared, cannot be encoded or is over 4 KiB, so it is reported as failed. When the encoder threw, the entry carries both exceptions. |
Model, Member, Route, FailureType, Reason |
| 1010 | BridgeSnapshotCaptureFailed |
Error | A state snapshot writer throws. | Model, Route, ErrorType |
| 1011 | BridgeSnapshotDeliveryFailed |
Error | A host rejects a state or delta frame. | Model, Route, ErrorType |
| 1012 | BridgeCollectionKeysRejected |
Error | A [RunicCollection] has a null row or a null, empty or duplicate key, so the route withholds its state. |
Model, Field, Key, Route |
| 1013 | AcceptedWorkFailed |
Error | A task owned by an AcceptedWorkScope faults. |
ErrorType |
| 1014 | AcceptedWorkDomainFailed |
Debug | A task owned by an AcceptedWorkScope throws a declared failure. |
FailureType |
| 1015 | PathWatchProblem |
Warning | A RunicPathWatcher started without a failed callback meets a problem it works around: lost events, a watch limit or a watch that could not start. Always written to Trace. |
Problem |
| 1016 | PathWatchPolling |
Debug | A RunicPathWatcher started without a failed callback polls for a directory that does not exist yet, such as a state directory created on the first start. Not written to Trace. |
Problem |
| 1020 | ViewMountFailed |
Error | A .NET View fails to mount. | Route, ErrorType |
| 1021 | ViewRemountFailed |
Error | A View fails to mount again after a reconnect. | Route, ErrorType |
| 1033 | ModelContextReleaseFailed |
Error | A window session's own model context fails to shut down in the background. | ErrorType |
| 1040 | RoutedRegionRouteIncompatible |
Error | A ReactiveUI ReactiveRoutedRegion<T> receives a ViewModel that is not a T, so it presents no content. |
Region, Model |
| 1041 | RoutedRegionRouterFailed |
Error | The router observed by a ReactiveRoutedRegion<T> fails; the region keeps its last content. |
Region, ErrorType |
| 1042 | ReactiveCommandFailed |
Error | A bridged ReactiveUI command reports on ThrownExceptions an exception that no Bridge call received, while a generated Bridge presents it, or a command or object observed with ObserveBridgeExceptions(logger) reports one; declared RunicFailureExceptions and cancellations are not logged. |
Source (Model.Command for the Bridge; the command expression or sourceName for ObserveBridgeExceptions), ErrorType |
| 1043 | ReactiveSchedulerOutsideModelTurn |
Warning | Work is scheduled on the model-context main-thread scheduler that AddRunicReactiveModelContext() installs, outside a model turn, so it runs on the scheduler it replaced. Logged once per ViewModel type found on the call stack, and once for work with no ViewModel there, such as a timer. Runic Desktop, CS-WebUI and RunicWindowTestHost.Create pass the application's logger factory when a window opens; before that, or without one, it goes to Trace. |
Source, Fallback |
| 1050 | WindowRegistrationMissing |
Error | ValidateWindow or OpenWindowAsync finds a Window's generated Bridge unregistered, on any host. |
Code, DiagnosticMessage, Remediation |
| 1051 | WindowReleaseFailed |
Error | IRunicWindowService fails to release a Window it opened, after that Window closed. |
Window, ErrorType |
| 1052 | WindowCloseGuardFailed |
Error | A guarded Window's CanCloseAsync throws; the window stays open. A departure guard that throws is logged by the navigator as 1060 instead. |
Window, ErrorType |
| 1053 | WindowCloseGuardUnavailable |
Warning | A Window with GuardClose opens on a presentation that cannot keep its window open, such as CS-WebUI or a browser, so a user close does not ask CanCloseAsync. |
Window, Presentation |
| 1054 | WindowFileDropFailed |
Error | IRunicFileDropTarget.OnFilesDroppedAsync of a Window's root ViewModel threw for a drop of files; later drops still arrive. |
ViewModel, FileCount, ErrorType |
| 1090 | ReactiveSchedulerKept |
Information | A Runic Desktop, CS-WebUI or test-host window opens while ReactiveUI's main-thread scheduler is one set before AddRunicReactiveModelContext(), so Runic's was not installed. Once per scheduler type; a UI dispatcher's scheduler, such as WPF's, is not logged. |
Scheduler |
| 1091 | ReactiveSchedulerReplaced |
Warning | Such a window opens after another scheduler replaced the installed one, for example a later RxAppBuilder BuildApp() with WithPlatformServices(). Call WithRunicModelContext() on that builder. Once per scheduler type; a UI dispatcher's scheduler is not logged. |
Scheduler |
| 1100 | HotReloadRestartRequired |
Error | A Hot Reload edit changed a generated Bridge contract; restart the application. The RUNIC_IDE_CONTRACT_RESTART_REQUIRED line also goes to standard error. |
Mismatch |
| 1101 | HotReloadRestartNotificationFailed |
Warning | A RunicBridgeHotReload.RestartRequired handler throws. |
ErrorType |
| 1102 | HotReloadRefreshFailed |
Warning | Refreshing a Bridge after a Hot Reload update throws. | Model, ErrorType |
| 1110 | ViewHostFallbackShown |
Warning | A WPF RunicViewHost cannot open its web presentation and shows its Fallback. |
ErrorType |
| 1111 | ViewHostFallbackFailed |
Error | Showing that fallback throws. | ErrorType |
| 1112 | ViewHostPresentationChangeFailed |
Error | A RunicViewHost presentation change throws outside its open. |
ErrorType |
| 1113 | ViewHostCleanupFailed |
Error | Releasing a RunicViewHost presentation throws. |
ErrorType |
| 1114 | ChildViewCleanupFailed |
Error | A WpfBridgeView fails to release after its presentation closed. |
ErrorType |
| 2000 | DesktopSnapshotDeliveryFailed |
Error | Runic Desktop cannot run a state delivery script. | Route, ErrorType |
| 2002 | DesktopWindowCloseAfterOpen |
Warning | RUNIC_APPLICATION_CLOSE_AFTER_OPEN=1 closed a Desktop window right after it opened. |
Variable |
| 2003 | DesktopPortalDiagnostic |
Warning or Debug | On Linux, xdg-desktop-portal reports a diagnostic for the portal services AddRunicPlatformServices() registers (notifications, status icon, login startup and inhibition): Warning for a rejected identity, an unavailable portal or a denied request, Debug for an accepted request or registration. |
Code, DiagnosticMessage, Remediation |
| 2004 | DesktopMenuCommandFailed |
Error | A menu bar command of a Window's root ViewModel (IRunicMenuCommandSource) or of RunicDesktopHost.MenuCommands threw, or binding the menu bar's ids failed. |
CommandId, ErrorType |
| 2100 | CsWebUiWindowShowFailed |
Error | CS-WebUI cannot show a window: it finds no browser, the selected browser does not exist or start, its page does not connect in time, or the WebView does not start. OpenWindowAsync fails with a RunicWindowConfigurationException. |
Window, Code, Browser, BrowserPath, DiagnosticMessage |
| 3000 | WindowCloseCancellationCallbackFailed |
Error | A Runic Desktop close-cancellation callback throws. | ErrorType |
| 3001 | WindowCloseConfirmationFailed |
Error | A Runic Desktop close confirmation throws; the Window stays open. | ErrorType |
| 3002 | DesktopConfigurationInvalid |
Error | DesktopHost.Validate finds a check that fails the window request. |
Code, Option, DiagnosticMessage, Remediation |
| 3003 | DesktopConfigurationLimited |
Warning | DesktopHost.Validate finds an option the presentation ignores or narrows, or a browser fallback opens without a permission grant. |
Code, Option, DiagnosticMessage, Remediation |
| 3004 | BrowserLaunchStalled |
Warning | A browser was still running but had requested nothing after ConnectionTimeout, so Runic Desktop relaunched it (DesktopHostOptions.BrowserLaunchAttempts). |
Attempt (the attempt that stalled), Attempts, TimeoutSeconds |
| 3005 | BridgeHandshakeExpired |
Warning | A Bridge WebSocket sent no token check, so Runic Desktop closed it and the page reconnects: after 10 seconds, or after 2 seconds when a newer WebSocket needs the only connection. | ConnectionId, SilentSeconds, Reason |
| 3006 | DesktopShutdownRequested |
Information | Ctrl+C, SIGTERM or SIGHUP asks DesktopEventLoop.Run to close the Desktop windows. |
Reason |
| 3007 | DesktopShutdownFailed |
Error | Closing the Desktop windows for that shutdown throws. | ErrorType |
| 3008 | BrowserProfileNotDeleted |
Warning | A generated browser profile still cannot be deleted a minute after its browser closed; it stays in the temporary folder. | |
| 3009 | DesktopDiagnosticSinkFailed |
Warning | DesktopHostOptions.DiagnosticSink throws for a diagnostic. |
Code, ErrorType |
| 3010 | EmbeddedWindowDiagnostic |
Warning or Error | An embedded window reports an event of its own: on Linux, web-process-terminated when the WebKitGTK web process crashed and the page was reloaded (Warning), or was not reloaded after three reloads within a minute (Error). |
Code, DiagnosticMessage, Remediation |
| 3011 | WindowStateHandlerFailed |
Error | A DesktopWindow.StateChanged handler threw; later handlers and events still run. |
ErrorType |
| 3012 | FileDropHandlerFailed |
Error | A DesktopWindow.FilesDropped handler threw; later handlers and drops still run. |
ErrorType |
Model is the ViewModel type name, Member the command or property, and
Route the Bridge route (a content presentation's route is per instance, such
as content12). A declared failure is an expected outcome, so it is logged at
Debug, still with its exception; FailureType is the failure value's type.
Events 1000-1012, 1020, 1021, 1050-1053 and 1100-1102 use the category Runic.Application.Views
(RunicViewsTelemetry.LogCategory); the Hot Reload events 1100-1102 use the
logger of the window session that owns the Bridge. Events 1110-1114 use
Runic.Application.Views.Wpf and the factory of the host's Services. Event 1033 here is a window session's
own model context failing to shut down, and uses the session's logger.
Events 1013 and 1014 use the logger passed to new AcceptedWorkScope(logger),
or Trace without one.
Navigation (1060-1075) and the model context (1030-1033) log under
Runic.Navigation and Runic.Navigation.RunicModelContext; see the
Runic.Navigation logging
section. Events 2000, 2002 and 2004 use Runic.Application.Views.Desktop, and event 2100
uses Runic.Application.Views.CsWebUi and the factory of the Window's scope. Events 3000-3012
use Runic.Desktop and DesktopHostOptions.LoggerFactory, which RunicDesktopHost.Run
and RunicViewHost fill from the application's services.
Events 1040-1043, 1090 and 1091 come from Runic.Application.Views.ReactiveUI (and its .Reactive
flavor) and also use Runic.Application.Views. Events 1040 and 1041 need the
ReactiveRoutedRegion<T>(router, loggerFactory) constructor, which a
ViewModel can call with an ILoggerFactory injected from DI, as
examples/notes-reactive-views does. Without a factory, the region writes the
same messages to Trace as described above. Region is the region's ViewModel
type name T. The Bridge logs event 1042 through the Window's logger factory,
with the ViewModel type and command property, such as EditorViewModel.SaveCommand, as
Source; an exception that a Bridge call received is logged as 1000 or 1004
instead. ObserveBridgeExceptions(logger) replaces that logging for its command
and uses the logger passed to it; Source is the observed command's expression,
such as SaveCommand, or sourceName.
Bridge calls are traced by the ActivitySource and measured by the Meter
named Runic.Application.Views (RunicViewsTelemetry.ActivitySourceName and
MeterName). With OpenTelemetry, call AddSource(...) and AddMeter(...)
with these names. Tag values are generated contract names, outcomes and
exception types, never arguments or state.
| Span | Covers |
|---|---|
runic.bridge.command |
A synchronous or awaited command. |
runic.bridge.set |
A property setter. |
runic.bridge.write |
A checked field write. |
runic.bridge.operation.start |
Admitting an operation. |
runic.bridge.operation |
Running an admitted operation. |
Spans are named <kind> <Model>.<Member> and have the tags
runic.bridge.kind, runic.bridge.model, runic.bridge.member,
runic.bridge.route and runic.bridge.outcome. The outcome is ok,
rejected, cancelled, failed, domain_failed or disconnected. An
admission can also be unavailable, capacity, duplicate or expired, and
an operation whose result could not be delivered is delivery_failed. A failed
span has the status Error and error.type. A domain_failed call is an
expected outcome: its span status stays Unset, it has no error.type, and
runic.bridge.failures does not count it.
| Instrument | Type | Unit | Tags |
|---|---|---|---|
runic.bridge.calls |
Counter | {call} |
kind, model, member, outcome, error.type |
runic.bridge.call.duration |
Histogram | s |
kind, model, member, outcome, error.type |
runic.bridge.failures |
Counter | {failure} |
kind (a call kind, snapshot.capture, snapshot.delivery, collection.keys or mount), model, member, error.type |
runic.bridge.snapshot.frames |
Counter | {frame} |
model, runic.bridge.frame (state, delta or failure) |
runic.bridge.snapshot.size |
Histogram | By |
model, frame |
runic.bridge.snapshot.delivery.duration |
Histogram | s |
model, frame |
runic.bridge.snapshot.recoveries |
Counter | {snapshot} |
model |
runic.bridge.snapshot.queue.depth |
ObservableUpDownCounter | {frame} |
none |
Route names are span tags but not metric tags, because content routes identify one instance. The duration histograms advise buckets from 0.5 ms to 10 s, and the size histogram from 256 B to 4 MiB. The queue depth reports the frames waiting for slow hosts across all Bridges when it is observed.
Incremental generation
The generator caches successful multi-view generation in its C# obj output
directory. It hashes the model/dependency assemblies, generator assemblies,
output-affecting options, and generated output contents. Missing or edited
outputs invalidate the cache; unchanged generated files keep their timestamps.
Set RUNIC_BRIDGE_CODEGEN_FORCE=1 or RUNIC_BRIDGE_CODEGEN_CACHE=0 to bypass it.
The configured frontend build command (RunicApplicationFrontendBuildCommand) runs
when one of its inputs is newer than
obj/<Configuration>/<TargetFramework>/runic-bridge-frontend.stamp, when a file
that the last build left in <frontend>/dist is missing (a deleted dist
included), or when its build state changed. The inputs are the files under
RunicApplicationFrontendDirectory (excluding node_modules, dist, build, bin, obj
and dot-directories), the generated TypeScript, the project file and
RunicApplicationFrontendInput items. The build state is a hash of the build
command, RunicApplicationHost, NODE_ENV and the environment variables named by
RunicApplicationFrontendBuildEnvironment items; only the hash is written, to
obj/runic-bridge-frontend.state. dist serves every configuration and target
framework, so a build whose state differs from the one that last built dist
builds it again, for example Debug after a Release build with other variables. Generated TypeScript is rewritten only when its
content changes, so C#-only edits skip the frontend build.
RunicApplicationFrontendInput items are the extension point for inputs outside
the frontend directory, such as workspace packages, shared components imported
through a Vite alias, or generated assets; declare them in the project, or add
them from a target that runs before RunicViewsGenerateBridge (the translations
editor is an example). Delete the stamp or rebuild to force a frontend build.
The copy to www/ mirrors dist. Each copy records the files it wrote, with
their last write times, in obj/, and the next one deletes those that no
frontend has any more, such as assets with an earlier hash, or a
runic-views.js that no host copies any more. A recorded file whose time has
changed since, because someone else wrote it, stays. Nothing else is deleted:
the host-specific client scripts, a dotnet runic dev document and files the
application puts in www/ itself stay. Files that a build before 0.7.0-preview.9 left behind are not recorded;
delete www/ once to drop them. Set
RunicApplicationFrontendBuildEnabled=false when another tool, such as a running dev server,
owns the frontend build; dotnet runic dev does this while its development
server runs.
Generated C# and TypeScript files start with // <auto-generated />. Only such
files are removed from the output directories when a ViewModel disappears, so
RunicApplicationFrontendGeneratedDirectory may point into a directory with hand-written modules.
Watching files
A ViewModel whose state comes from the file system can refresh when the file
system changes, rather than when a page connects. RunicPathWatcher watches
files, symbolic links, directories and Git checkouts, debounces their events,
and calls back in a turn of the model context, so the callback sets state
directly:
public sealed partial class OverviewViewModel : ObservableObject, IDisposable
{
private readonly RunicPathWatcher _watcher;
private readonly RunicWatchTarget _system = RunicWatchTarget.Link("/run/current-system");
public OverviewViewModel(IRunicModelContext context, RunicPathWatcherOptions? watching = null)
{
_watcher = RunicPathWatcher.Start(context,
[
_system,
RunicWatchTarget.Directory("/nix/var/nix/profiles", "system-*-link"),
RunicWatchTarget.GitHead(_configurationCheckout),
],
change => _ = RefreshAsync(change.Contains(_system)),
failure => Problem = failure.ToString(),
watching);
_ = RefreshAsync(systemChanged: true); // start first, then read the initial state
}
public void Dispose() => _watcher.Dispose();
}
| Target | Reports | For example |
|---|---|---|
File(path) |
The file is created, deleted, replaced, renamed or written. | A settings file, flake.lock |
Link(path) |
The path a symbolic link leads to, through every link in its chain, differs: the link was created, removed or re-pointed. Changes inside the final file or directory are not reported. | /run/current-system, ~/.local/state/nix/profiles/home-manager |
Directory(path, pattern) |
The set of entry names matching the pattern differs: one is added, removed or renamed. Writes to entries, and a rename onto a name that already existed (the same set of names), are not reported. | /nix/var/nix/profiles/system-*-link |
Files(path, pattern) |
An entry matching the pattern changes as for File: it is added, removed, renamed, written, or replaced by renaming another file over it, as atomic writers update a file. Where it polls, the watcher reads each matching entry's size and write time. |
A directory of records that other processes rewrite, *.json |
GitHead(repository) |
The commit HEAD resolves to, or the branch it names, differs: a commit, reset, switch, detach or pull. Works in linked worktrees, whose .git is a file, follows symbolic refs (five levels, as Git does) and supports the reftable format. git pack-refs, fetches and other branches are not reported with the files format. |
A configuration checkout |
After an event the watcher waits for Debounce (250 ms) without another
event, at most ten intervals, then reads the affected targets and reports
those whose state changed. A burst of writes, ln -sfn's temporary link or
Git's .lock files therefore produce one callback or none. For the same
reason a target that changes and changes back within that window, such as a
link re-pointed from A to B and back to A, is not reported, except a File
or Files target, for which an event that names a matching entry alone counts. GitHead reads HEAD, the loose ref
and packed-refs itself; it starts no git process. With the reftable format
any update of the ref stack counts, of the repository's and, in a linked
worktree, of the worktree's own, so other branches and fetches are reported
too. To react to work-tree edits, add Directory or File targets for them.
Problems never throw on a watcher thread. They reach the failed callback,
in a model turn, or System.Diagnostics.Trace (event 1015) without one, and the watcher
keeps working by polling every PollingInterval (2 s) where it needs to. A
missing directory is expected, as on an application's first start, so without
a failed callback it is event 1016 at Debug level instead of a warning:
RunicPathWatchErrorKind |
When | Then |
|---|---|---|
Missing |
A directory a target needs does not exist. | Polls until it does, then reports the change. |
Overflow |
The platform dropped events. | Reads the affected targets again. |
WatchLimit |
fs.inotify.max_user_watches or max_user_instances is exhausted. |
Polls; retries the watch on each poll. |
Failed |
A watch could not start or stopped. | Polls; retries the watch on each poll. |
NotGitRepository |
A GitHead directory exists but holds no repository. |
Watches it for .git or HEAD to appear and reports the change; reads it only at the 30-second backstop. |
Dispose the watcher with its ViewModel. It also stops when the model context
closes, and no callback starts after Dispose returns in a model turn. Set
UsePolling to read the targets every PollingInterval instead of watching.
To test a ViewModel that watches, pass it the options of a
RunicPathWatchTester.
Refresh on change or on connect
- Refresh on change when the ViewModel's state mirrors something outside the application that can change while a page is open: a profile, a running system, a Git checkout, a configuration file. Start a watcher with the ViewModel, read the state once after starting it, and refresh in its callback. A page that connects later receives the current snapshot; it does not need to call a refresh command.
- Refresh on connect, or on a command, when nothing can notify you: a remote service, a command's output, or state that is expensive to read and only interesting when someone looks. A watcher would only add work. Keep a manual refresh command for state that a watcher covers only partly.
- Combine them when the change is the trigger but reading is expensive: let the callback mark the state stale or start a superseding read, rather than reading everything for each event.
Before, a page had to ask: nix-manager polled every 30 seconds while a page was open and observed again when a page connected after more than 5 seconds.
Platforms
RunicPathWatcher uses .NET's FileSystemWatcher on the directory that holds
each watched entry, so a link replaced by a rename is seen in its parent, and
shares one watcher per directory across the process. It also watches that
directory's parent: inotify and FSEvents keep a watch on a deleted directory
and report nothing for a new directory of the same name, so the watcher
compares the directory's identity (device, inode and birth time, since ext4
reuses a freed inode at once, or volume and file index on Windows) when the parent reports it, on each read and when another watcher
joins, and starts a new watch when it was replaced. PathWatcherTests run the
first five rows on Linux, Windows and macOS in CI, and check that an event,
not a poll, reported each change; the network and limit rows follow the
platforms' documentation.
| Case | Linux (inotify) | Windows (ReadDirectoryChangesW) |
macOS (FSEvents) |
|---|---|---|---|
| Link re-pointed by rename | Rename event in the parent directory | Same | Same; events can arrive later and merged, the watcher reads the targets again |
| File written in place | Write event | Write event | Write event, merged with others |
| Directory entry added or removed | Create, delete or rename event | Same | Same |
| Watched directory deleted and recreated | The parent's watch reports the delete; the watcher reports Missing, and the parent's event reports the return |
Same | Same; FSEvents reports nothing for the watched directory itself, only its parent does |
Directory replaced before any read (rm -rf and git clone) |
The old watch reports nothing more; the parent's event leads to a new watch | Same | Same |
| Network, FUSE and virtiofs file systems | Polls: inotify reports only local changes (NFS, CIFS, sshfs, a virtual machine's shared folders) | Polls network drives and UNC paths | Polls network volumes |
| Limits | Each FileSystemWatcher holds an inotify instance (fs.inotify.max_user_instances, 128 by default per user) and each directory a watch (fs.inotify.max_user_watches), its parent one more; GitHead adds one per directory under refs/heads. Exhausted: WatchLimit and polling |
64 KiB event buffer; overflow: Overflow |
Overflow: Overflow |
| Creating symbolic links | Allowed | Needs Developer Mode or elevation; reading them does not | Allowed |
Even with working events, the watcher reads its targets every 30 seconds (or
every PollingInterval when that is longer) to recover from events a
platform lost.
Accepted application work
Cancellation requests that a command stop; its invocation can finish before
the underlying task completes. In particular, ReactiveUI bridge execution
can settle a cancelled invocation and release its subscription while an
uncooperative task continues recovery. WindowContentSession.BeginCloseAsync
and its result's Completion drain bridge invocations. Their completion does
not establish that independently accepted application work, durable changes,
or recovery has finished.
Own such work with an AcceptedWorkScope in the scoped model or service that
owns its resources. Pass a factory so ownership is registered before the task
starts, and include recovery, publication, and cleanup in that task:
private readonly AcceptedWorkScope _acceptedWork;
public NotesModel(ILogger<NotesModel> logger) => _acceptedWork = new(logger);
public Task SaveAsync(CancellationToken cancellationToken) =>
_acceptedWork.RunAsync(() => SaveAndRecoverAsync(cancellationToken));
public Task SaveAndCloseAsync(CancellationToken cancellationToken) =>
_acceptedWork.RunAsync(async () =>
{
await SaveAndRecoverAsync(cancellationToken);
// Closing disposes this model; that drain does not wait for this task.
await _window.CloseAsync();
});
public async ValueTask DisposeAsync()
{
await _acceptedWork.DisposeAsync();
// Release the model's resources after its accepted tasks finish.
}
RunAsync invokes its factory synchronously on the calling thread and returns
the original task, preserving its result, failure, or cancellation. Concurrent
drain includes accepted factories that have not yet returned a task. A null
task or synchronous factory exception releases that admission and throws to
the caller. Calls after drain starts throw InvalidOperationException without
invoking the factory.
DrainAsync(cancellationToken) closes admission and waits for actual task
completion, including faulted and cancelled tasks. Its cancellation token only
stops that observer's wait; it neither cancels nor abandons owned work.
DisposeAsync is idempotent and waits without cancellation. Keep resources
alive through this drain even if a bridge invocation or an earlier drain
observer has already been cancelled.
Each owned task keeps its original outcome, and drain does not throw for it.
Because an invocation may stop observing a task before it fails, the scope logs
each fault once: event 1013 at Error, or 1014 at Debug for a declared
RunicFailureException (see Logging and telemetry).
Cancellations are not logged. Pass the model's logger to the constructor;
without one, entries go to System.Diagnostics.Trace.
A drain or disposal started from accepted work of the same scope, such as the
"Save and close" task above, waits for all other accepted work but not for the
calling task or the accepted tasks awaiting it, which cannot finish until the
drain does. This applies to code that inherits the task's execution context,
including awaited continuations and Task.Run. Code that continues in that
task after the drain must not use the resources the owner released. If the
close is queued to another thread without the execution context, do not await
its disposal from the accepted task; let the task return instead.
The scope does not serialize operations, dispatch model changes, request cancellation, or implement recovery. The application still owns those policies and must return a task that covers all work requiring its resources.
Troubleshooting
- The frontend cannot resolve
./generated/...imports. The build writes the generated modules toRunicApplicationFrontendGeneratedDirectory(Frontend/src/generatedby default). Build the .NET project once, or startdotnet runic dev, before you type-check or run the frontend on its own. - The build fails with
RUNICBRIDGE.... The error names the type and member and ends with a link to the ID's entry in the diagnostics catalog, which says how to fix it. See Generator diagnostics. - The frontend dependencies were not installed. The build installs them
when
package.jsonor a lock file changed since the last install, and when declared dependencies have nonode_modules. If that install did not run or failed, install them with the frontend's package manager, or rundotnet runic doctorto check the package manager and lock file. SetRunicApplicationFrontendInstallEnabled=falsewhen a workspace install owns them. - The build says only one ReactiveUI flavor can be used.
Runic.Application.Views.ReactiveUIandRunic.Application.Views.ReactiveUI.Reactivecannot both be referenced, and the.Reactiveadapter needsRunic.Navigation.ReactiveUI.Reactive. Keep the pair for one flavor; see Runic.Application.Views.ReactiveUI.Reactive.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Runic.Navigation (= 0.8.0-preview.5)
NuGet packages (5)
Showing the top 5 NuGet packages that depend on Runic.Application.Views:
| Package | Downloads |
|---|---|
|
Runic.Application.Testing
Headless Window and View test host for Runic applications. |
|
|
Runic.Application.Views.ReactiveUI
ReactiveUI 26 (Primitives flavor) model-context scheduling, routing, view location, and activation for Runic Views. |
|
|
Runic.Application.Views.Desktop
Runic Window and View host adapter for Runic Desktop. |
|
|
Runic.Application.Views.CsWebUi
CS-WebUI host adapter for Runic Window and View applications. |
|
|
Runic.Application.Views.ReactiveUI.Reactive
System.Reactive-flavor ReactiveUI model-context scheduling, routing, view location, and activation for Runic Views. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.8.0-preview.5 | 0 | 10/17/2026 |
| 0.8.0-preview.4 | 912 | 10/17/2026 |
| 0.8.0-preview.3 | 874 | 10/15/2026 |
| 0.8.0-preview.2 | 130 | 10/15/2026 |
| 0.8.0-preview.1 | 187 | 10/15/2026 |
| 0.7.0-preview.9 | 586 | 10/14/2026 |
| 0.7.0-preview.8 | 131 | 10/12/2026 |
| 0.7.0-preview.7 | 69 | 10/12/2026 |