Runic.Platform 0.11.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Runic.Platform --version 0.11.0
                    
NuGet\Install-Package Runic.Platform -Version 0.11.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Runic.Platform" Version="0.11.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Runic.Platform" Version="0.11.0" />
                    
Directory.Packages.props
<PackageReference Include="Runic.Platform" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Runic.Platform --version 0.11.0
                    
#r "nuget: Runic.Platform, 0.11.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Runic.Platform@0.11.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Runic.Platform&version=0.11.0
                    
Install as a Cake Addin
#tool nuget:?package=Runic.Platform&version=0.11.0
                    
Install as a Cake Tool

Runic.Platform

Host-independent native-service contracts for Runic applications.

Preview API targeting .NET 10. Native providers are explicitly selected; this package has no Desktop or ASP.NET Core dependency. Runic.Application.Views.Desktop selects one for each window with services.AddRunicPlatformServices().

Services return PlatformResult<T>: Success, Unavailable with a PlatformUnavailableReason, or Failed with a PlatformFailureCode. Operations without a value return PlatformResult<PlatformUnit>. The type names carry the Platform prefix so they do not collide with System.Reactive.Unit and similar names when both namespaces are imported.

Branch on the failure code. The set of codes is open: a minor release may add one, so give a switch a default arm. Unknown (0) is only the default value and is never reported; Other is a failure no other code describes, such as an unclassified native error. When a provider observed a native error, the Diagnostic of PlatformResult<T>.Failed, PickerResult<T>.Failed, FileCommitResult.NotCommitted and FileCommitResult.CommitUnknown carries a PlatformDiagnostic with its domain (HRESULT, Win32, errno, an NSError/GError domain or a D-Bus error name), numeric code and, where the operating system's error table describes the code, its message. It is for logs and support, not display. Free-form text from native services (GError and D-Bus messages, NSError descriptions) is left out, so a diagnostic contains no paths.

These services report what happens on the desktop as results and throw only for a caller mistake (ArgumentException), caller cancellation (OperationCanceledException) and use after disposal. Runic's administration APIs and the Desktop window host do the opposite and throw exceptions that keep the native error: they are called for one operation at a time, where a failure ends the operation, while a host capability is asked for by an application that must carry on when the desktop cannot help.

The result unions PlatformResult<T>, PickerResult<T> and FileCommitResult are closed: they keep their cases for the major version. Each enum says in its documentation whether it is open or closed.

IFileDialogs.OpenDirectoryAsync(new OpenDirectoryOptions()) returns a PickerResult<IDirectoryLease> for one existing local directory. Keep the selected lease alive while C# filesystem operations use LocalPath; it retains native access until disposal or presentation close. The path preserves the selected identity, including Unicode and escaped filename characters. Native paths that cannot be represented exactly in C# are refused. Leases and native grants remain in C#; expose only application DTOs to a frontend.

Presentation close releases every lease the presentation still owns; on macOS this ends security-scoped access, and LocalPath throws once closing starts. Native owner closure alone does not release leases. When application work that uses a lease can outlive the window, such as recovery owned by an AcceptedWorkScope, drain that work before the window closes, or dispose the lease at the end of the work itself:

await acceptedWork.DisposeAsync(); // Work using lease.LocalPath has finished; closing the window releases the rest.

User dismissal returns Dismissed, caller cancellation throws OperationCanceledException, and presentation shutdown returns Unavailable(OwnerClosed). The platform.directories.open capability reports whether the window's dialogs can select a directory. A file-dialog provider (IPickerBackend in Runic.Platform.Runtime) that implements OpenDirectoryAsync must also return true from SupportsDirectorySelection; the host always calls the provider's method and throws InvalidOperationException if an undeclared provider returns a result other than Unavailable.

PickerResult<T>.Selected and PlatformResult<T>.Success deconstruct, so a caller can match and take the value in one pattern:

if (await files.OpenFileAsync(new()) is PickerResult<IReadFileLease>.Selected(var lease))
{
    await using (lease) { /* read */ }
}

Where only the value matters, PlatformResult<T> also has Succeeded, TryGetValue(out T value) and Match, which calls one function per case:

var token = await secrets.GetAsync("com.example.Mail", account);
if (token.TryGetValue(out var value)) { /* value is null when none is stored */ }
string status = token.Match(_ => "ready", unavailable => $"unavailable: {unavailable.Reason}", failed => $"failed: {failed.Code}");

PickerResult<T> has TryGetValue(out T value) and Match too; its Match passes the Selected case, which also carries SelectedFilter, and has a function for Dismissed. The caller owns the selected resource either way:

if ((await files.OpenFileAsync(new())).TryGetValue(out var lease))
{
    await using (lease) { /* read */ }
}

File dialog options

OpenFileOptions and SaveFileOptions take Filters and InitialDirectory, OpenDirectoryOptions takes InitialDirectory, and SaveFileOptions keeps its SuggestedName. OpenFilesAsync selects several files at once:

var images = new FileDialogFilter("Images", ["png", "jpg"]);
var options = new OpenFileOptions
{
    Filters = [images, new FileDialogFilter("All files", ["*"])],
    InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
};
if (await files.OpenFilesAsync(options) is PickerResult<IReadFileLeaseList>.Selected(var leases))
{
    await using (leases)
        foreach (var lease in leases) { /* read each file */ }
}

A FileDialogFilter names a file type and lists its extensions without the dot, such as png or tar.gz, or * for every file. The first filter is selected when the dialog opens; no filters offer every file. Extensions match without regard to case on every platform. MimeTypes, such as image/png or image/*, widen a filter where the dialog matches content types: macOS maps them to uniform type identifiers and the Linux choosers match the file's MIME type. Windows filters by extension only, so a filter always lists its extensions:

var pictures = new FileDialogFilter("Pictures", ["png", "jpg", "webp"]) { MimeTypes = ["image/*"] };

Filters and the initial folder are hints to the dialog: a folder that does not exist is ignored, and the user can still choose or type a name that matches no filter on some platforms. Runic does not reject such a selection or add an extension to a saved name, so check the content you read. The options check their values when they are set and throw ArgumentException for an empty or wildcard extension, a relative folder or a null filter.

OpenFilesAsync opens every selected file before it returns: if one cannot be opened, all are released and the result is Failed. Each lease holds its file open until it, the list or the presentation is disposed. A provider without multiple selection returns Unavailable(BackendUnavailable), and the platform.files.openMultiple capability reports the same. OpenDirectoriesAsync selects several directories the same way and returns an IDirectoryLeaseList; the platform.directories.openMultiple capability reports whether the window's dialogs can.

PickerResult<T>.Selected.SelectedFilter is the filter that was active when the user confirmed the dialog, so a save can add that filter's extension or an import can pick a parser. It is null without filters, for directories and where the dialog does not say. macOS shows a filter pop-up, and so reports the filter, only when ShowFilterChoice is set on OpenFileOptions or SaveFileOptions:

var options = new SaveFileOptions("drawing") { Filters = [png, svg], ShowFilterChoice = true };
if (await files.SaveFileAsync(options) is PickerResult<ISaveFileLease>.Selected { SelectedFilter: var format } saved)
    await using (saved.Value) { /* write the drawing as format ?? png */ }
Option Windows macOS Linux portal (GTK 3 and GTK 4) Linux GTK 3 native chooser
Filters File type list (SetFileTypes) The union of every filter restricts the panel (allowedContentTypes); with ShowFilterChoice, a pop-up below the panel selects one filter FileChooser filters and current_filter File type list
MIME types Ignored: extensions only UTType(mimeType:); image/*, audio/*, video/*, text/*, font/* and model/* map to their abstract types, other wildcards are skipped (1, type) patterns in each filter gtk_file_filter_add_mime_type
Chosen filter (SelectedFilter) GetFileTypeIndex The pop-up's filter with ShowFilterChoice, otherwise null The response's current_filter, when the portal returns it gtk_file_chooser_get_filter
Multiple selection FOS_ALLOWMULTISELECT allowsMultipleSelection FileChooser multiple select-multiple
Multiple directories FOS_PICKFOLDERS with FOS_ALLOWMULTISELECT canChooseDirectories with allowsMultipleSelection, security scope per URL FileChooser directory with multiple (version 3 and later) SELECT_FOLDER with select-multiple
Initial folder SetFolder, which overrides the folder Windows remembers directoryURL FileChooser current_folder; ignored when opening by portals before FileChooser version 4 current-folder
Suggested name SetFileName nameFieldStringValue FileChooser current_name current-name

On macOS a save panel with filters may add the first allowed extension, or with ShowFilterChoice the chosen filter's, to a name the user types without one.

Window services

IWindowPlatformServices groups the file dialogs, clipboard and file launcher of one window, and reports their capabilities together under WindowPlatformServices.CapabilityIds. Host adapters register it, and its parts, per window scope through services.AddRunicPlatformServices(), so a ViewModel can take IFileDialogs, ITextClipboard, IDesktopClipboard, IDesktopFileLauncher, IDesktopMonitors, IDesktopMessageBoxes or IWindowPlatformServices in its constructor. Runic.Application.Views.Desktop binds them to the native window when it opens. A host without a native owner registers WindowPlatformServices.Unavailable(reason), whose operations and capabilities all report that reason. Tests can use the same instance.

AnnounceAsync(message, priority) asks the screen reader to speak a status message for the window, such as "Saved", through the platform's accessibility API: the native counterpart of an ARIA live region. PlatformAnnouncementPriority.Polite, the default, waits for what the screen reader is saying and Assertive interrupts it, as aria-live does; the enum is closed. It returns Success, or Unavailable with OwnerUnavailable before the window opens or without a native owner, OwnerClosed after it closed and BackendUnavailable when the window cannot announce. An IWindowPlatformServices implementation that does not override it reports OwnerUnavailable.

Monitors lists the desktop's monitors (IDesktopMonitors): ListAsync returns each DesktopMonitor with an opaque Id, a DisplayName, its Bounds and WorkArea as DesktopRect in the units of the window's position and size, its Scale and whether it IsPrimary, the primary one first. WatchAsync reports the list, then the whole list again after each change, and ends with the result when reading fails. The monitors come from the window's host, such as DesktopHost.Monitors, under PlatformCapabilityIds.Monitors; DesktopMonitors.Unavailable(reason) stands in where there are none, and an implementation that does not override the member reports OwnerUnavailable.

Message boxes

MessageBoxes (IDesktopMessageBoxes) shows a native message box modal to the window: a task dialog on Windows, a sheet (NSAlert) on macOS, a GtkMessageDialog for a GTK 3 window and a GtkAlertDialog (GTK 4.10 and later) for a GTK 4 window. Use one where the page cannot answer, such as before it loaded, while it is busy, or for a close confirmation in GuardClose that must follow the platform's button order and keys; screen readers announce it as a system alert. Keep ordinary prompts in the page, where they follow the application's design.

var save = new DesktopMessageBoxButton("save", "Save") { Role = DesktopMessageBoxButtonRole.Default, AccessKey = 's' };
var discard = new DesktopMessageBoxButton("discard", "Don't Save") { Role = DesktopMessageBoxButtonRole.Destructive, AccessKey = 'n' };
var cancel = new DesktopMessageBoxButton("cancel", "Cancel") { Role = DesktopMessageBoxButtonRole.Cancel };
var result = await services.MessageBoxes.ShowAsync(new DesktopMessageBox("Save changes to “Notes”?")
{
    Detail = "Your changes are lost if you don't save them.",
    Kind = DesktopMessageBoxKind.Warning,
    Buttons = [save, discard, cancel],
});
if (result is PlatformResult<DesktopMessageBoxButton?>.Success { Value.Id: "save" }) await SaveAsync();

ShowAsync returns the chosen button, one of Buttons, or null when the box had no buttons of the application (it then shows one localized OK button) or GTK closed it without one. A box has at most six buttons with unique ids, at most one Default button, which Return chooses, and one Cancel button, which Esc and the close button choose; without a cancel button Windows and macOS can only be answered with a button. Each platform places the buttons by role: Windows shows the default button first and the cancel button last, macOS the default button rightmost with the cancel button beside it, and GTK the default button last with the cancel button before it. Esc in a GTK box without a cancel button closes it with null. Destructive is styled on macOS 11 and later and on GTK 3; Windows and GTK 4 alert dialogs have no destructive style, and GTK 4 alert dialogs show no icon. AccessKey marks a letter as for menu items; & and _ in a label are shown as written. Title is the caption on Windows (the owner window's title when null) and the GTK 3 title; macOS sheets and GTK 4 alert dialogs have none. A right-to-left window on Windows and GTK 3 gets a right-to-left box; macOS and GTK 4 alert dialogs follow the application's language. Cancelling the token closes the box and throws. The capability is PlatformCapabilityIds.MessageBoxes: OwnerUnavailable before the window opens and on hosts without a native owner, OwnerClosed after it closed (closing the window closes its box), BackendUnavailable where Windows cannot activate Common Controls 6 or GTK 4 is older than 4.10. DesktopMessageBoxes.Unavailable(reason) stands in where there are none, and an implementation that does not override the member reports OwnerUnavailable.

Clipboard

Clipboard reads and writes bounded Unicode text (ITextClipboard). The clipboard of Runic's hosts is also an IDesktopClipboard, which a ViewModel can take directly or reach with services.Clipboard as IDesktopClipboard: HTML, Rich Text Format, PNG images and lists of local files, from C# code such as a command or a background export. A page can still use the browser's clipboard, but only under a user gesture and the web view's permission rules, and never for files.

await clipboard.WriteAsync(new DesktopClipboardContent
{
    Text = "Name\tTotal\nApples\t3",
    Html = "<table><tr><th>Name</th><th>Total</th></tr><tr><td>Apples</td><td>3</td></tr></table>",
});
if (await clipboard.GetFormatsAsync() is PlatformResult<DesktopClipboardFormats>.Success { Value: var formats }
    && formats.HasFlag(DesktopClipboardFormats.Image)
    && await clipboard.ReadImageAsync(16 << 20) is PlatformResult<byte[]?>.Success { Value: { } png })
    await InsertImageAsync(png);

WriteAsync replaces the clipboard with every format the DesktopClipboardContent sets, offered together, so each application pastes the one it understands best. Set at least one format; text, HTML and RTF must not contain NUL characters, the image must be a PNG file, and files must be fully qualified (at most 4096) and exist, or the write reports InvalidData or NotFound and changes nothing. Each read takes only the format it names and is bounded, as ReadTextAsync is: ReadHtmlAsync and ReadRtfAsync in characters, ReadImageAsync in PNG bytes and ReadFilesAsync in paths; more reports TooLarge, and a successful null or empty list means the format is not there. GetFormatsAsync reads which formats are there without reading any.

Windows macOS Linux (GTK 3 and GTK 4)
HTML CF_HTML ("HTML Format"); a write wraps a fragment in a document, a read returns the copied fragment public.html, as written text/html, as written
RTF "Rich Text Format" public.rtf text/rtf (application/rtf and text/richtext read too)
Image "PNG", else a bitmap converted to PNG; a write also offers CF_DIBV5 for applications that read only bitmaps public.png, else public.tiff converted to PNG image/png, else another image format converted to PNG by gdk-pixbuf (GTK 3) or GDK (GTK 4)
Files CF_HDROP with a copy drop effect One public.file-url item per file text/uri-list and x-special/gnome-copied-files; GTK 4 also the FileTransfer portal in Flatpak
Watch WM_CLIPBOARDUPDATE The change count, compared four times a second owner-change (GTK 3), changed (GTK 4)

RTF is bytes: each character is one byte (Latin-1), and RTF's escapes such as \u8364? carry other characters. WatchAsync reports the formats now and again after each change, never the content: read it when the user pastes, as other applications' content is theirs. The capabilities are ClipboardReadData, ClipboardWriteData and ClipboardWatch; a custom ITextClipboard stays text-only, and a provider without the formats reports them BackendUnavailable. On Wayland the compositor tells only a focused application about the clipboard, so the watch reports changes when a window of the application gains focus.

Badges and progress

SetBadgeAsync shows a count (DesktopBadge.Count, 99+ above 99) or, without one, a dot that asks for attention; SetProgressAsync shows a DesktopProgress from 0 to 1, or indeterminate progress with a null value. null removes either.

await services.SetBadgeAsync(new DesktopBadge { Count = unread, Description = $"{unread} unread messages" });
await services.SetProgressAsync(new DesktopProgress(done / (double)total));
await services.SetProgressAsync(null);

On Windows both belong to the window's taskbar button: Runic draws the badge as an overlay icon at the window's scale, with Description as its accessible text, and colours the bar for DesktopProgressState.Paused and Error; the state comes back after Explorer restarts. On macOS both belong to the application's Dock icon, which all windows share: the most recent call wins, and a window's badge and progress go when it closes; the Dock draws Paused and Error as a normal bar. On Linux they belong to the application's launcher entry, as on macOS, through the Unity launcher API that KDE Plasma, Ubuntu Dock and Dash to Dock read (stock GNOME shows nothing; the call still succeeds); a dot is shown as a request for attention, and paused, failed or indeterminate progress as normal or no bar. The entry needs the application ID of AddRunicPlatformServices(applicationId) or the Flatpak ID, else the calls report BackendUnavailable (Runic.Platform.Linux.Portal). The capabilities are TaskbarBadge and TaskbarProgress: OwnerUnavailable before the window opens and on hosts without a native owner, OwnerClosed after it closed. An implementation that does not override the members reports OwnerUnavailable.

The capability identifiers are constants of PlatformCapabilityIds, so a typo does not compile:

var snapshot = services.GetSnapshot();
bool canSave = snapshot.Statuses[PlatformCapabilityIds.FilesSave] is CapabilityStatus.Available;

IDesktopFileLauncher.LaunchUriAsync(uri) opens a web or mail link in the user's default browser or mail client, or a Windows Settings page, and reports the outcome as a PlatformResult (capability PlatformCapabilityIds.UrisLaunch):

var result = await launcher.LaunchUriAsync(new Uri("https://example.org/help"));

It accepts only absolute http, https, mailto and ms-settings URIs. A relative URI, a path (an absolute path parses as a file: URI, a UNC path too) and every other scheme throw ArgumentException before anything native runs, so a link never starts a program; open local files with LaunchAsync. A mailto URI that names an attachment (a header field attach, attachment or another name beginning with attach, in any case or escaping) throws too: some mail clients attach that file without the user choosing it. The URI comes from trusted application code: validate links a web page supplies before passing them on.

LaunchAsync(path) opens a local file in its default application. On Windows and macOS, DesktopFileOperation.Open returns Failed(PermissionDenied) instead of running programs, scripts, installers, shortcuts and disk images; Linux applies the desktop portal's policy. The block lists are best effort, so do not Open files from untrusted sources, such as downloads or names a page supplies: use DesktopFileOperation.Reveal or DesktopFileOperation.ChooseApplication instead.

Platform Mechanism Outcomes
Linux (GTK 3 and GTK 4, also in Flatpak and Snap) The OpenURI desktop portal The portal may let the user choose an application; cancelling is Failed(UserDismissed)
Windows ShellExecuteEx with the URI and the shell's URL association (the user's default), with zone checks Without a handler, for example no mail client, Windows 11 offers to find an application and reports success; older shells report Unavailable(BackendUnavailable)
macOS NSWorkspace openURL:configuration:completionHandler: No application for the scheme: Failed(Other) with the NSError domain and code (macOS 26: NSCocoaErrorDomain 256)

Success means the desktop accepted the URI, not that an application showed it.

ms-settings: URIs, such as ms-settings:display or ms-settings:defaultapps, open the Windows Settings app through the same shell call. The URI must be exactly ms-settings:<page>, where the page is 1 to 64 letters, digits or hyphens (compared lower-case), with no path, query, fragment or percent-escapes. Pages ending in -action, such as windowsupdate-action, start an action instead of showing a page and are refused. Other values throw ArgumentException on every platform. Linux and macOS accept a valid page and return Unavailable(BackendUnavailable) without a native call, so cross-platform code can offer a Settings link on Windows and handle the result elsewhere:

var result = await launcher.LaunchUriAsync(new Uri("ms-settings:defaultapps"));
if (result is PlatformResult<PlatformUnit>.Unavailable)
    ShowManualInstructions();

IDesktopSettings is application-scoped, so AddRunicPlatformServices() registers one singleton for it. A host or platform without a provider registers DesktopSettings.Unavailable(reason): reads return Unavailable(reason), and a watch emits that value once and then waits. IDesktopNotifications is registered the same way; without a provider it is DesktopNotifications.Unavailable(reason), whose requests return Unavailable(reason) and which never raises Activated. IDesktopStatusIcon, IDesktopLoginStartup and IDesktopInhibition are application singletons too; without a provider they are DesktopStatusIcon.Unavailable(reason), DesktopLoginStartup.Unavailable(reason) and DesktopInhibition.Unavailable(reason). Unavailable inhibition supports no effects, still validates the effects and reason as a provider does, and returns Unavailable(reason) from every acquisition.

To decide whether to offer a feature at all, read an application service's status with DesktopServiceStatus.Get(service). A service an Unavailable(reason) factory created, for example because no provider supports the host or Windows notifications have no application ID, reports CapabilityStatus.Unavailable with that reason; any other service reports CapabilityStatus.Available. Available means a native provider was created, not that each request succeeds: requests still report what the desktop does, including Unavailable when a desktop service turns out to be missing. A service implements IDesktopServiceStatus to report its own status.

bool offerRememberMe = DesktopServiceStatus.Get(secrets) is CapabilityStatus.Available;

DesktopAppearance.TextScale is the desktop's text size preference as a factor of the default size: the Windows Accessibility "Text size" setting (1 to 2.25) and, on Linux, GNOME's text scaling factor (0.5 to 3, "Large Text" is 1.25), which the GNOME and GTK portal backends expose through the Settings portal. macOS has no system-wide text size that applications can read, so it is null there, as on a desktop that has no such setting, such as Plasma. Multiply font sizes by it, for example a page's root font size; it is independent of the display scale. A watch reports a change of it like any other preference.

A notification's Activated event is untrusted input. DesktopNotificationActivation.IsTrusted is true when the provider matched the activation to a notification the application showed, so its identifiers and ActivationUri are the ones the application supplied: on Windows and macOS, and on Linux for a notification the running process showed. On Linux an activation can also arrive after a restart, when the process no longer knows the notification, and any process on the session bus can send one; such an activation is not trusted, and its ActivationUri is null unless it is an http, https or mailto URI. Check the identifiers of an untrusted activation against the application's own state before acting on them.

Hosts without a native owner

One services.AddRunicPlatformServices() call registers every service above on both hosts, so a ViewModel that takes any of them resolves on Runic Desktop and on CS-WebUI. Window services need the window's native owner; application services need none. A window has no native owner when it is shown in an installed browser after an embedded-window fallback, served under RUNIC_APPLICATION_SERVE_ONLY=1, or opened on CS-WebUI; a ViewModel resolved outside a window's scope has none either.

Service Desktop window with a native owner Desktop window without one, or outside a window scope CS-WebUI
IWindowPlatformServices, IFileDialogs, ITextClipboard, IDesktopClipboard, IDesktopFileLauncher Native provider; OwnerClosed after the window closes Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
Badges and progress, IWindowPlatformServices.SetBadgeAsync and SetProgressAsync The taskbar button (Windows, also a WPF window's), the Dock tile (macOS) or the Unity launcher entry (GTK 3 and GTK 4, with an application ID) Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
IDesktopMessageBoxes, IWindowPlatformServices.MessageBoxes Task dialogs (Windows, also a WPF window's), sheets (macOS), GTK 3 message dialogs and GTK 4 alert dialogs Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
IDesktopMonitors, IWindowPlatformServices.Monitors The host's monitors (Windows, macOS, GTK 3 and GTK 4; a WPF window's on Windows) Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
Announcements, IWindowPlatformServices.AnnounceAsync, and DesktopPlatformServices.PresentAsync Native window Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
Window state, DesktopPlatformServices.GetWindowState The window's bounds and state, also the last ones after it closes Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
Dropped files, IRunicFileDropTarget Native paths and promised files, once per drop None None
Drags out, IWindowPlatformServices.StartDragAsync The window's native drag where it has DesktopWindowCapabilities.FileDrag (Windows, macOS, a WPF window); BackendUnavailable elsewhere Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
Menu bar commands, IRunicMenuCommandSource The native menu bar (macOS; the GTK 3 and GTK 4 windows' own bar), while the window is key, and DesktopWindow.TryExecuteMenuItemAsync None None
Context menus, IWindowPlatformServices.ShowContextMenuAsync The window's native menu where it has DesktopWindowCapabilities.ContextMenu (macOS, GTK 3, GTK 4); BackendUnavailable elsewhere Unavailable(OwnerUnavailable) Unavailable(OwnerUnavailable)
IDesktopSettings, IDesktopNotifications, IDesktopStatusIcon, IDesktopLoginStartup, IDesktopInhibition, IDesktopSecretStore, IDesktopTrash, IDesktopFileTransfers, IDesktopVolumes, IDesktopNetworkStatus, IDesktopApplications, IDesktopThumbnails Operating system provider Operating system provider, the same instance Unavailable(ProviderNotConfigured)

On Runic Desktop the application services behave the same with or without a native owner. The macOS settings, notifications and status icon need the AppKit event loop that the Desktop host runs, as their providers describe. Linux inhibition sends no parent window, so the desktop does not tie it to a window. CS-WebUI selects no native provider; register one before calling AddRunicPlatformServices() to use an application service there, for example WindowsPlatformProvider.CreateInhibition() or new PortalApplication("org.example.Notes").CreateInhibition(). Existing registrations are kept on both hosts.

The application composes IDesktopPrivilegedActions itself, because it needs declared actions and a provider package, and claims ApplicationInstance before it starts a host.

Status icon menus (DesktopStatusIconState.Menu), context menus (IWindowPlatformServices.ShowContextMenuAsync) and the Runic Desktop menu bar (DesktopHostOptions.MenuBar) share one model:

Type Is
DesktopMenuEntry The base of every entry. The set is open: a minor release may add entry kinds, so give a switch over entries a default arm. Code that walks a menu skips entries it does not know.
DesktopMenuItem(Title, CommandId) An item that runs a command, with IsEnabled, IsChecked (null for a plain item), Shortcut, CommandParameter, AccessKey and RadioGroup
DesktopMenuSeparator A line between groups of entries
DesktopMenu(Title, Items) A submenu, or a menu of the menu bar, with IsEnabled, AccessKey and the menu bar's Role
DesktopContextMenuOptions Where a context menu opens: X and Y in CSS pixels of the page's viewport, or at the pointer
DesktopShortcut, DesktopShortcutModifiers A key and its modifiers, written as Primary+Shift+S
ImmutableArray<DesktopMenuEntry> menu =
[
    new DesktopMenuItem(Title: "Open window", CommandId: "open"),
    new DesktopMenuItem(Title: "Start at login", CommandId: "login") { IsChecked = startsAtLogin },
    new DesktopMenuSeparator(),
    new DesktopMenu("More", [new DesktopMenuItem(Title: "About", CommandId: "about")]),
    new DesktopMenuItem(Title: "Quit", CommandId: "quit"),
];

The title comes first. A title is literal user-visible text: not blank, at most 128 characters and without NUL. It has no mnemonic markers: providers show & and _ as written. A command id is an application-owned routing key, never a command line: 1 to 64 ASCII letters, digits, dots, underscores or hyphens. Ids starting with runic., in any letter case, are reserved for Runic's standard commands. CommandParameter is passed to the command with the item: at most 1024 characters without NUL, application-owned like the id. AccessKey is one letter or digit, which Windows and Linux underline: Runic places the marker, or shows the key in parentheses after a title without it. Items with the same RadioGroup in one menu level are a radio group: the name follows the command id rule, each item needs IsChecked, and at most one is checked. Each surface checks these rules when it receives a menu and throws an ArgumentException that names the id or title and the rule.

Status icon menu Context menu Menu bar (Runic Desktop)
Command ids Unique with their CommandParameter in the menu May repeat May repeat
Limits Four nested submenus, 256 entries Four nested submenus, 256 entries, at least one entry None beyond the rules above
IsEnabled, IsChecked, RadioGroup Shown as declared Shown as declared Shown as declared; an item is also disabled while no command target can run it
Shortcut Ignored Shown as a hint, not registered The item's shortcut; on macOS its key equivalent
Role, AccessKey Ignored Role ignored; access key on Windows and Linux Where the menu goes; access key on Windows and Linux
Choosing an item IDesktopStatusIcon.Activated with Kind MenuItem, the item's CommandId and CommandParameter Success with the chosen item, null when dismissed The command target's Execute(DesktopMenuItem)

The status icon reports CommandParameter on every platform, so one command id can serve several items with different parameters, such as one per recent file; two items with the same id and parameter are rejected.

DesktopStatusIconState.LayoutDirection mirrors the Windows status icon's menu for DesktopLayoutDirection.RightToLeft (TPM_LAYOUTRTL, items read right to left); the default keeps the platform's layout. Linux and macOS desktops lay the menu out for the user's language and ignore it.

IWindowPlatformServices.ShowContextMenuAsync checks its arguments, then shows the window's native menu, or reports Unavailable: OwnerUnavailable before the window opens and on a host without a native owner, OwnerClosed after it closed, and BackendUnavailable for a window without native context menus. An implementation written before 0.11 reports OwnerUnavailable. The capability snapshot reports PlatformCapabilityIds.ContextMenu (platform.contextMenu) and PlatformCapabilityIds.MenuBar (platform.menuBar, a bar shown natively in the window or a global menu bar; an in-page bar does not count).

// From the page's contextmenu event: showRowMenu(row.key, event.clientX, event.clientY).
async Task ShowRowMenuAsync(string key, double x, double y)
{
    var result = await platform.ShowContextMenuAsync(
        [new DesktopMenuItem("Rename", "row.rename") { CommandParameter = key }, new DesktopMenuSeparator(),
         new DesktopMenuItem("Delete", "row.delete") { CommandParameter = key }],
        new DesktopContextMenuOptions { X = x, Y = y });
    if (result is PlatformResult<DesktopMenuItem?>.Success { Value: { } chosen }) Run(chosen.CommandId, chosen.CommandParameter);
}

Global shortcuts

IDesktopGlobalShortcuts binds named actions to keys that work while another application has the focus, such as a quick-note window or a push-to-talk key. It is one service for the application, created by a provider's CreateGlobalShortcuts() or registered by AddRunicPlatformServices, and disposing it removes every binding.

var bindings = await shortcuts.BindAsync(
[
    new DesktopGlobalShortcut("quick-note", "Open a quick note")
    {
        PreferredShortcut = DesktopShortcut.TryParse(settings.QuickNoteKey, out var key) ? key : null,
    },
]);
shortcuts.Activated += (_, activation) => { if (activation.Id == "quick-note") OpenQuickNote(activation.PlatformContext); };

BindAsync replaces the whole set: at most 32 actions, each with a unique id under the command id rule (1 to 64 ASCII letters, digits, dots, underscores or hyphens) and a description of at most 256 characters, which the Linux desktop shows where the user assigns keys. It throws ArgumentException for a set outside these rules. The result has one DesktopGlobalShortcutBinding per action, in order, with its Status:

Status Means
Bound The key starts the action; Shortcut and TriggerDescription show it
Unbound No key: the action has no PreferredShortcut, or the user left it unassigned
Conflict Another application, or another action of the set, has the key
Refused The platform does not allow the key, such as F12 on Windows, ⌘Q or Ctrl+C, or a character the keyboard layout cannot type

The set is open: handle unknown statuses. A key that cannot be bound fails alone, and the result is still Success. ListAsync returns the current bindings, and BindingsChanged reports bindings the user or the desktop changed.

Windows macOS Linux
Mechanism RegisterHotKey on a message-only window on its own thread Carbon RegisterEventHotKey; needs no Accessibility permission, and the Desktop event loop must run The desktop's org.freedesktop.portal.GlobalShortcuts (KDE Plasma 5.27+, GNOME 48+, Hyprland); elsewhere Unavailable(BackendUnavailable)
Who chooses the key The application, from its own settings The application, from its own settings The user, in the desktop's settings; PreferredShortcut is a suggestion and the desktop keeps the user's choice per application and action id
ConfigureAsync Unavailable(BackendUnavailable) Unavailable(BackendUnavailable) Opens the desktop's dialog where the portal has version 2
Punctuation and letters The key that types the character on the current layout, with Shift when the layout needs it; a character that needs AltGr is refused The key that types the character on the current ASCII-capable layout, registered again after the input source changes (BindingsChanged) The desktop's; TriggerDescription is its text and Shortcut is null where that text has no DesktopShortcut form
Refused F12, kept for debuggers, and the editing and window shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z, Ctrl+X, Ctrl+C, Ctrl+V, Ctrl+A, Alt+F4 and F10 alone) The standard menus' shortcuts (⌘Z, ⇧⌘Z, ⌘X, ⌘C, ⌘V, ⌘A, ⌘Q, ⌘H, ⌥⌘H, ⌘W, ⌘M), F21 to F24, and any combination RegisterEventHotKey rejects Decided by the desktop; an action the user left unassigned is Unbound
Activation context None None The portal's activation token

The Linux portal needs the application's ID: outside Flatpak and Snap, pass the installed desktop ID (AddRunicPlatformServices("org.example.App") or new PortalApplication("org.example.App")); without one it reports Failed(PermissionDenied). Runic does not grab keys on X11 itself.

On Windows and macOS the application owns the binding: record the user's choice in a settings page with shortcutFromEvent from @runic-artifex/views, keep its DesktopShortcut.ToString() text in the application's settings and pass it as PreferredShortcut at startup. Runic stores nothing. Only key presses are reported, not releases, and a held key reports one press. Activated and BindingsChanged are raised on a worker thread; present a window through its presentation, passing PlatformContext where there is one. DesktopGlobalShortcuts.Unavailable(reason) stands in on hosts without a provider.

Dragging out of a window

IWindowPlatformServices.StartDragAsync(data) checks DesktopDragData (at least one of existing absolute Paths, Text without NUL up to 1 MiB, or absolute non-file: Uris, at most 1024 each; Move and Link need paths), then starts the window's native drag and returns the drop's effect, or reports Unavailable: OwnerUnavailable before the window opens and on a host without a native owner, OwnerClosed after it closed, and BackendUnavailable for a window that cannot start drags. Success(None) means the drag was cancelled, dropped nowhere, or no mouse button was held when the call arrived. An implementation written before 0.11 reports OwnerUnavailable. The capability snapshot reports PlatformCapabilityIds.DragOut (platform.drag.out).

// From the page's dragstart event, after event.preventDefault(): dragItem(row.key).
async Task DragItemAsync(string key)
{
    var result = await platform.StartDragAsync(new DesktopDragData { Paths = [Find(key).Path] });
    if (result is PlatformResult<DesktopDragEffects>.Success { Value: not DesktopDragEffects.None }) Status = "Dragged";
}

Opening files and URLs

IDesktopOpenRequests raises Opened with the files and URLs the user opens with the application: its registered URL schemes, such as an OAuth callback, and its file types, at launch and while it runs. Linux and Windows start the application with them as arguments, which ApplicationInstance forwards to the running instance; macOS sends open events to the running application. DesktopPlatformServices.CreateOpenRequests in Runic.Application.Views.Desktop selects the provider:

var launch = ApplicationLaunch.FromProcess(args);
// After ApplicationInstance.ClaimAsync made this process the running instance; on macOS on the main thread,
// before the first window.
await using var opens = DesktopPlatformServices.CreateOpenRequests(new(["org.example.notes"]) { IncludeFiles = true }, instance, launch);
opens.Opened += (_, request) => { /* worker thread: validate each URI, then dispatch to a window */ };

DesktopOpenRequests.FromLaunch is the argument parser on its own. Treat every opened URL and file as untrusted input: any web page can open a URL with the application's scheme. The opening files and URLs design lists what each platform's package registers.

DesktopOpenOptions.WebLinkHosts adds https links to the application's own hosts, such as notes.example.org: macOS universal links and Windows app URI handlers. Only a link whose host is one of them exactly, with the default port and no user information, is delivered; every other https URL stays with the application. The association with the host is the application's (an Associated Domains entitlement and an apple-app-site-association file on macOS, an AppUriHandler and a windows-app-web-link file on Windows).

Registering schemes and file types

An application that installs itself, such as a zip or dotnet run, registers its schemes and file types for the current user with IDesktopOpenRegistration (DesktopPlatformServices.CreateOpenRegistration(applicationId)), when the user agrees. A registration never makes the application the default and never replaces another application's entry: it checks every item first and reports Conflict, with nothing written. A packaged or sandboxed application reports Unavailable: its package declares what it opens. macOS registers the running bundle, whose Info.plist must declare each item.

var registration = new DesktopOpenRegistration("Notes")
{
    UrlSchemes = ["org.example.notes"],
    FileTypes = [new(".notes") { MimeType = "application/x-example-notes", Description = "Notes document" }],
};
var result = await DesktopPlatformServices.CreateOpenRegistration("org.example.Notes").RegisterAsync(registration);

Keeping access to files

IDesktopFileBookmarks (DesktopPlatformServices.CreateFileBookmarks()) keeps access to a file across launches, as a recent-files list needs: on macOS a security-scoped bookmark inside the App Sandbox, elsewhere the path. Store the bytes CreateAsync returns, and dispose the access ResolveAsync returns when done with the file; when it IsStale, create and store a new bookmark.

Secret storage

IDesktopSecretStore keeps secrets, such as access tokens, in the user's credential store: the Secret Service on Linux (GNOME Keyring, KeePassXC, KWallet), Credential Manager on Windows and the login keychain on macOS. AddRunicPlatformServices() registers one for the application; it needs no window.

await secrets.SetAsync("com.example.Mail", account, refreshToken);
if (await secrets.GetAsync("com.example.Mail", account) is PlatformResult<string?>.Success(var token)) { /* null: none stored */ }
await secrets.DeleteAsync("com.example.Mail", account); // Success(false): none stored

Services and accounts are 1 to 256 characters without NUL. Text secrets are UTF-8 of at most 2560 bytes on every platform; a longer one is Failed(TooLarge). GetBytesAsync and SetBytesAsync store binary secrets of up to 64 KiB (DesktopSecretStore.MaximumBinarySecretBytes) on every platform. A service and account hold one item in either form: GetAsync reads a binary item that is UTF-8 text, and GetBytesAsync reads a text item as its bytes. Clear the arrays the store returns once used. A store implemented before 0.11 reports the binary members Unavailable(BackendUnavailable).

var background = DesktopPlatformServices.CreateSecretStore("com.example.Mail",
    new DesktopSecretStoreOptions { AllowUserInteraction = false, RequireEncryption = true });
if (await background.GetBytesAsync("com.example.Mail", account) is PlatformResult<byte[]?>.Failed { Code: PlatformFailureCode.Locked })
    { /* refresh later, when the user is present */ }

DesktopSecretStoreOptions are store-wide: AllowUserInteraction = false fails with Failed(Locked) instead of an unlock or access prompt (best effort for the Linux Secret portal); RequireEncryption refuses a Linux Secret Service without an encrypted session; UseDataProtectionKeychain uses the macOS data-protection keychain, which needs a team-signed bundle with the keychain-access-groups entitlement. To register such a store, add it to the services before AddRunicPlatformServices(). No reachable store is Unavailable(BackendUnavailable); a locked store that cannot ask the user to unlock it is Failed(Locked); a dismissed unlock or access prompt is Failed(UserDismissed). A request may wait while the store asks the user. Hosts without a provider register DesktopSecretStore.Unavailable(reason). The secret storage design has the per-platform behaviour and its verification.

Trash

IDesktopTrash moves a file or directory the user deletes to the trash, from where the user can restore it: the freedesktop.org Trash on Linux, with the Trash portal inside Flatpak and Snap, the Recycle Bin on Windows and the Trash on macOS. .NET's File.Delete and Directory.Delete delete permanently. AddRunicPlatformServices() registers one for the application; it needs no window.

switch (await trash.MoveToTrashAsync(document.Path))
{
    case PlatformResult<PlatformUnit>.Success: break;
    case PlatformResult<PlatformUnit>.Failed { Code: PlatformFailureCode.NotFound }: break; // Already gone.
    case PlatformResult<PlatformUnit>.Unavailable: /* No trash here: ask before deleting permanently. */ break;
    case PlatformResult<PlatformUnit>.Failed failed: /* Report failed.Code. */ break;
}

The path is a fully qualified local path to a file, a directory with its contents, or a symbolic link, which is trashed itself. A location without a usable trash, such as a network share, is Unavailable(BackendUnavailable), and the item is never deleted permanently instead: it stays where it was unless the result is Success. A missing item is Failed(NotFound), a refused move Failed(PermissionDenied). Hosts without a provider register DesktopTrash.Unavailable(reason). The trash design has the per-platform behaviour and its verification.

MoveAllToTrashAsync trashes several items one after another with progress, each with its own result. On Windows and on Linux outside Flatpak and Snap, ListAsync lists what is in the trash, RestoreAsync puts an item back where it came from without replacing anything there, and EmptyAsync deletes every item permanently; ask the user before emptying. macOS and sandboxed Linux report Unavailable(BackendUnavailable) for these three:

var results = await trash.MoveAllToTrashAsync(selection, new Progress<DesktopTrashProgress>(p => ShowProgress(p.Completed, p.Total)));
if (await trash.ListAsync() is PlatformResult<ImmutableArray<DesktopTrashItem>>.Success(var items))
    foreach (var item in items.OrderByDescending(static i => i.DeletedAt)) { /* item.OriginalPath, item.Size, item.IsDirectory */ }

Desktop applications

IDesktopApplications is the desktop's application database: what a file is, which installed applications open it, the user's default for it, and their icons. A file manager uses it for "Open With", and opens a file with the application the user picks. AddRunicPlatformServices() registers one for the application; it needs no window. Linux reads the freedesktop.org desktop entries, shared MIME-info database, mimeapps.list and icon themes directly; Windows uses the shell's file and protocol associations, and macOS Launch Services with uniform type identifiers.

if (await applications.GetContentTypeAsync(path) is PlatformResult<DesktopContentType>.Success(var type)
    && await applications.GetApplicationsForAsync(type.Id) is PlatformResult<ImmutableArray<DesktopApplicationInfo>>.Success(var handlers))
{
    // handlers[0] is the default; show each one's Name and GetIconAsync(DesktopIconRequest.ForApplication(app.Id, 32)).
    await applications.LaunchAsync(handlers[0].Id, [path]);
}

Identifiers are the platform's own: on Linux a desktop file ID such as org.gnome.TextEditor.desktop and a MIME type such as text/plain; on Windows an executable's path (an AppUserModelID for a packaged application) and a MIME type or an extension such as .txt; on macOS a bundle identifier such as com.apple.TextEdit and a uniform type identifier such as public.plain-text. x-scheme-handler/<scheme> names a URL scheme everywhere. LaunchAsync starts only an installed application the desktop knows, and passes each path or URI as one argument, never through a shell. SetDefaultApplicationAsync changes one key of the user's mimeapps.list on Linux, asks Launch Services on macOS, which may confirm with the user, and reports PermissionDenied on Windows, where only the user chooses defaults (in ms-settings:defaultapps). Icons are PNG or SVG bytes. Inside Flatpak and Snap the host's applications are out of reach and the application methods report Unavailable(BackendUnavailable); use IDesktopFileLauncher there. Hosts without a provider register DesktopApplications.Unavailable(reason). The desktop applications design has the per-platform behaviour, the algorithms and their verification.

Copy and move

IDesktopFileTransfers copies and moves files and directories as a file manager does: with progress, cancellation and a decision for each conflict. AddRunicPlatformServices() registers one for the application; every operating system uses the same managed engine (Runic.Platform.Runtime.LocalFileTransfers.Create()).

var request = new FileTransferRequest([report, photos], destinationDirectory);
if (await transfers.CopyAsync(request, cancellationToken) is PlatformResult<FileTransferSummary>.Success(var summary))
    Console.WriteLine($"{summary.Transferred} copied, {summary.Skipped} skipped");

Each source goes into the destination directory under its own name. A file is written under a hidden temporary name and renamed into place once complete, so a directory listing, or a RunicPathWatcher watching it, never sees a partial file. A move renames a source that stays on its file system and copies one that does not, removing the source only after its whole item is complete.

When the transfer stops, by an error, a conflict answered Cancel or cancellation, items completed before stay done, and what the item in progress did is put back: its copies are removed, renamed entries move back, and an entry it replaced returns. The first error is Failed with NotFound, PermissionDenied, Conflict (a conflict without a handler), UserDismissed (a conflict answered Cancel), IOError, such as for a full disk, or Other for a native error no other code describes. Caller cancellation throws OperationCanceledException.

What a transfer keeps

Kept Not kept
File content Owner and group
Last write time of files and directories Access control lists and extended attributes, including macOS quarantine and tags
Permission bits on Linux and macOS (UnixFileMode) Alternate data streams on Windows
Read-only, hidden and archive attributes on Windows macOS resource forks and Finder information
Symbolic links, as links; they are never followed Hard links: each becomes a separate file
Directory structure, empty directories included Sparse regions: holes are written out; creation and access times

Named pipes, sockets and devices are skipped and counted in Skipped. On Windows, copying a symbolic link needs Developer Mode or an elevated process; otherwise the transfer fails with PermissionDenied. Copying is block by block in .NET: reflinks, CopyFileEx and clonefile are not used.

Conflicts

When an entry of the destination has a source's name, the transfer calls ResolveConflict with a FileTransferConflict, the source and the existing entry with their sizes and times, and waits for a FileConflictResolution: Skip, Replace, KeepBoth (report (2).txt), Merge (directories only: each conflict inside is decided in turn) or Cancel. ApplyToAll answers every later conflict of the same kind, between two directories or not. Without a handler the transfer stops at the first conflict with Failed(Conflict). Copying an item into its own directory keeps both without asking; moving it there does nothing.

The handler is called on the SynchronizationContext that was current when the transfer started. In a ViewModel command that is the model context, so the handler can ask the page through an interaction, and progress reported through a Progress<T> created in the command updates state in a later turn.

Show progress and ask the page

This ReactiveUI ViewModel pastes files the user copied in its own listing. The command is a bridged operation, so the page can cancel it; its progress is state, and conflicts reach the page through an interaction. The paths stay in .NET; the page sees names and sizes.

/// <summary>How far a paste is.</summary>
public sealed record PasteProgress(int Items, int TotalItems, double Bytes, double TotalBytes);

/// <summary>An entry of the folder that a pasted one would replace.</summary>
public sealed record ConflictPrompt(string Name, bool IsDirectory, double Bytes, DateTimeOffset Modified, bool CanMerge);

/// <summary>The user's decision.</summary>
public sealed record ConflictAnswer(FileConflictAction Action, bool ApplyToAll);

/// <summary>What a paste did.</summary>
public sealed record PasteResult(int Transferred, int Skipped, bool Cancelled);

public sealed partial class FolderViewModel : ReactiveObject
{
    private readonly IDesktopFileTransfers _transfers;
    private ImmutableArray<string> _copied = [];  // Set by the folder's own Copy command.

    public FolderViewModel(IDesktopFileTransfers transfers, string location)
    {
        _transfers = transfers;
        Location = location;
        PasteCommand = ReactiveCommand.CreateFromTask(PasteAsync);
    }

    public string Location { get; }

    /// <summary>The paste in progress, or null.</summary>
    [Reactive]
    public partial PasteProgress? Pasting { get; private set; }

    /// <summary>Asks which way to resolve a conflict.</summary>
    public Interaction<ConflictPrompt, ConflictAnswer> ResolveConflict { get; } = new();

    /// <summary>Copies the entries copied earlier into this folder.</summary>
    public ReactiveCommand<RxVoid, PasteResult> PasteCommand { get; }

    private async Task<PasteResult> PasteAsync(CancellationToken cancellationToken)
    {
        var request = new FileTransferRequest(_copied, Location)
        {
            // Created in the command's turn: each report sets state in a later turn of the model context.
            Progress = new Progress<FileTransferProgress>(progress =>
                Pasting = new(progress.CompletedItems, progress.TotalItems, progress.CompletedBytes, progress.TotalBytes)),
            // Called in the model context; the page answers through the interaction.
            ResolveConflict = async (conflict, token) =>
            {
                var prompt = new ConflictPrompt(Path.GetFileName(conflict.DestinationPath), conflict.Destination.IsDirectory,
                    conflict.Destination.Length, conflict.Destination.LastWriteTime, conflict.CanMerge);
                var answer = await ResolveConflict.Handle(prompt); // The command's cancellation cancels the question.
                return new FileConflictResolution(answer.Action) { ApplyToAll = answer.ApplyToAll };
            },
        };
        try
        {
            return await _transfers.CopyAsync(request, cancellationToken) switch
            {
                PlatformResult<FileTransferSummary>.Success(var done) => new(done.Transferred, done.Skipped, Cancelled: false),
                PlatformResult<FileTransferSummary>.Failed { Code: PlatformFailureCode.UserDismissed } => new(0, 0, Cancelled: true),
                var stopped => throw new IOException($"The paste stopped: {stopped}"),
            };
        }
        finally { Pasting = null; }
    }
}

The page starts the operation, renders pasting from the snapshot, cancels through the operation controller and answers conflicts while it is mounted:

import { createOperationController } from "@runic-artifex/views";

const paste = createOperationController(() => folder.startPaste());
const unregister = folder.interactions.resolveConflict.handle(
  async (prompt, { signal }) => askReplaceDialog(prompt, signal), // resolves to { action: "Replace", applyToAll: false }
);
folder.subscribe(state => renderProgress(state.pasting));
pasteButton.onclick = () => void paste.run();
cancelButton.onclick = () => void paste.cancel();

Cancel aborts the command's CancellationToken, which stops the transfer within a block and puts back the item in progress. The operations guide covers pending state and cancellation feedback. The file transfer design has the full behaviour and its verification.

Watch a directory

RunicPathWatcher in Runic.Application.Views watches the directory a file manager shows, also while a transfer writes into it: RunicWatchTarget.Directory(location) reports entries added, removed and renamed, through inotify on Linux, ReadDirectoryChangesW on Windows and FSEvents on macOS, and polls network file systems. Hidden temporary files that a transfer writes start with ., so a pattern such as *.txt, or skipping names that start with ., shows only complete entries. See Watching files.

Volumes

IDesktopVolumes lists the desktop's storage volumes, such as USB sticks, SD cards and other disks, mounts, unmounts and ejects them, and reports them as they appear, change and disappear. AddRunicPlatformServices() registers one for the application; it needs no window and is disposed with the service provider.

await foreach (var change in volumes.WatchAsync(cancellationToken))
{
    switch (change)
    {
        case PlatformResult<DesktopVolumeChange>.Success(DesktopVolumeChange.Snapshot(var all)): Show(all); break;
        case PlatformResult<DesktopVolumeChange>.Success(DesktopVolumeChange.Added(var volume)): Add(volume); break;
        case PlatformResult<DesktopVolumeChange>.Success(DesktopVolumeChange.Changed(var volume)): Update(volume); break;
        case PlatformResult<DesktopVolumeChange>.Success(DesktopVolumeChange.Removed(var id)): Remove(id); break;
        case PlatformResult<DesktopVolumeChange>.Unavailable: ShowNone(); break; // A Snapshot follows if the service returns.
    }
}

if (await volumes.MountAsync(stick.Id) is PlatformResult<DesktopVolume>.Success(var mounted))
    OpenFolder(mounted.MountPaths[0]);

The watch starts with a Snapshot of every volume. DesktopVolume.Id is opaque; MountPaths is empty while the volume is not mounted, and CanMount, CanUnmount and CanEject say what applies now. Mounting may show an authorization prompt: a refusal is Failed(PermissionDenied), a dismissed prompt Failed(UserDismissed), a volume in use Failed(ResourceBusy), a locked encrypted volume Failed(Locked), and a volume that is gone Failed(NotFound). Unmounting a volume that is not mounted succeeds; ejecting unmounts every volume of the drive first. An operation the volume or platform does not offer is Unavailable(BackendUnavailable).

A locked encrypted volume has IsLocked, IsEncrypted and CanMount = false. Its UnlockMethod says how to unlock it: SystemPrompt with UnlockAsync(id), which shows the system's own prompt (BitLocker on Windows), or Passphrase with UnlockAsync(id, passphrase, token) (APFS on macOS). MountAsync(id, options, token) mounts read-only or with platform mount options where the platform takes them (Linux and macOS); Windows mounts volumes itself and takes none. A LUKS volume on Linux unlocks with its passphrase through UDisks2, which returns the cleartext filesystem as a new volume:

if (vault is { IsLocked: true, UnlockMethod: DesktopVolumeUnlockMethod.Passphrase })
{
    char[] passphrase = AskPassphrase();
    try { await volumes.UnlockAsync(vault.Id, passphrase, cancellationToken); }
    finally { Array.Clear(passphrase); }
}
await volumes.MountAsync(stick.Id, new DesktopVolumeMountOptions { ReadOnly = true }, cancellationToken);
Platform Provider
Linux UDisks2 on the system bus, which asks Polkit; inside Flatpak add --system-talk-name=org.freedesktop.UDisks2. A LUKS volume unlocks with its passphrase; mount options go to UDisks2, which accepts those its configuration permits
Windows The volume management API: drive letters and mounted folders. Windows mounts volumes itself, so MountAsync and UnmountAsync only succeed for a volume already in that state and otherwise report Unavailable(BackendUnavailable); EjectAsync is "Safely Remove Hardware", or opens an optical drive's tray. A locked BitLocker volume is listed and unlocks through Explorer's prompt
macOS Disk Arbitration, as Finder does; an application with a file open on the volume makes an unmount or eject Failed(ResourceBusy). The startup disk's volumes are never unmounted or ejected. A locked APFS volume is listed and unlocks with its passphrase; mount options go to DADiskMountWithArguments

Hosts without a provider register DesktopVolumes.Unavailable(reason). The volumes design has the per-platform behaviour and its verification.

Network status

IDesktopNetworkStatus reports what the operating system knows about the network: ReadAsync returns a DesktopNetworkState, and WatchAsync reports the current state, then each change; a change that leaves the state as it was is not reported. IsAvailable says whether a usable route exists (false always comes with Connectivity None), Connectivity how far it reaches as the operating system's own check found (Local, Limited, CaptivePortal, Internet, or Unknown where it does not check), and IsMetered whether the connection is metered, null when unknown. It is the operating system's view, not a promise that a server answers: try the request, and use the state to explain a failure or postpone work, such as a large download on a metered connection or until the user signed in to a captive portal.

await foreach (var result in network.WatchAsync(cancellationToken))
    if (result is PlatformResult<DesktopNetworkState>.Success(var state))
        ShowOfflineBanner(!state.IsAvailable || state.Connectivity == DesktopNetworkConnectivity.CaptivePortal);
Platform Source
Windows The Network List Manager's connectivity hint (Windows 10 version 2004 and later; earlier Unavailable(BackendUnavailable)). Its check reports a captive portal, and a network whose check failed otherwise, as constrained internet access, which is CaptivePortal. A fixed or variable cost, a connection over its data limit and roaming are metered
macOS Network.framework's path monitor. macOS has no public captive-portal or internet check: an available network has Unknown connectivity. Expensive paths (cellular, a personal hotspot) and constrained ones (Low Data Mode) are metered
Linux The NetworkMonitor desktop portal, which xdg-desktop-portal answers for sandboxed and unsandboxed applications; NetworkManager on the system bus where no portal answers. Both report captive portals and metered connections. Neither answering is Unavailable(BackendUnavailable) with the network-monitor-unavailable diagnostic

When the source goes away, the watch reports Unavailable and continues with a fresh state once it is back; without a source it reports Unavailable once and waits. DesktopPlatformServices.CreateNetworkStatus() and AddRunicPlatformServices() create one instance for the application. Hosts without a provider register DesktopNetworkStatus.Unavailable(reason).

Thumbnails

IDesktopThumbnails returns the thumbnail the desktop's file manager shows for a file, as a PNG image, so a file browser can show pictures, videos and documents without decoding them itself. AddRunicPlatformServices() registers one for the application; it needs no window.

if (await thumbnails.GetThumbnailAsync(path, 128) is PlatformResult<DesktopThumbnail?>.Success({ } thumbnail))
    ShowImage("data:image/png;base64," + Convert.ToBase64String(thumbnail.Png.AsSpan()), thumbnail.Width, thumbnail.Height);
else
    ShowIcon(path); // No thumbnail: show the type's icon, from IDesktopApplications.GetIconAsync.

size is the largest edge to display, up to 1024 pixels. For a high-density display, pass the window's scale too: GetThumbnailAsync(path, 128, scale: 2, cancellationToken) asks for 256 device pixels, at most 1024 (scales 1 to 16, DesktopThumbnails.MaximumScale). The image keeps the file's aspect ratio and can differ in size from the request, so scale it to display: Linux returns the cached size at least as large (128, 256, 512 or 1024 pixels), Windows and macOS at most size. A file without a thumbnail, such as a type no thumbnailer handles or one whose thumbnailing failed, is Success(null); an icon is never returned instead. A missing file is Failed(NotFound).

Platform Provider
Linux The freedesktop.org thumbnail cache (~/.cache/thumbnails) that file managers fill; on a miss the session's org.freedesktop.thumbnails.Thumbnailer1 service, such as Tumbler on Xfce. GNOME and KDE run no such service, so there only thumbnails their file managers already made are returned. Inside Flatpak add --filesystem=xdg-cache/thumbnails:ro and --talk-name=org.freedesktop.thumbnails.Thumbnailer1.
Windows The shell's thumbnail providers and cache, as Explorer shows them
macOS Quick Look, as Finder shows them

Hosts without a provider register DesktopThumbnails.Unavailable(reason). The thumbnails design has the per-platform behaviour and its verification.

Privileged actions

IDesktopPrivilegedActions runs a step the application declared at build time, a fixed program with bounded arguments, as the administrator after the platform's authorization prompt. The program ran is Success with its exit code; a refused authorization is Failed(PermissionDenied), a dismissed prompt Failed(UserDismissed), and no authorization service Unavailable. Arguments are an argument vector, never a shell command line. DesktopPrivilegedActions.Unavailable(reason) is the provider for hosts and platforms without one. Linux uses Polkit and pkexec through Runic.Platform.Linux.Polkit, Windows a UAC-elevated helper through Runic.Platform.Windows.Elevation, and macOS a launch daemon and Authorization Services through Runic.Platform.MacOS (guide).

Declare privileged actions

Declarations are PrivilegedActionDeclaration values that this package's build targets generate from RunicPrivilegedAction items, in any project that references Runic.Platform as a package:

<ItemGroup>
  <RunicPrivilegedAction Include="org.example.app.activate"
                         Program="/usr/libexec/example/example-helper"
                         Verb="activate"
                         Description="Activate a system configuration"
                         Message="Authentication is required to activate the system configuration."
                         MaximumArguments="1"
                         ArgumentPattern="[A-Za-z0-9._+-]+" />
</ItemGroup>
Metadata Meaning
Item identity The action ID: lowercase reverse-DNS, at least two segments, the same on every platform.
Program The absolute, normalized Unix path of the privileged program. Linux runs it there; other platforms derive their own location from it unless their metadata below says otherwise. Required.
Verb The first argument, which selects the action. Every action needs its own verb, also across programs, and an action without a verb is allowed only on its own (RUNICPA003).
Description, Message What the authorization prompt shows, passed unchanged to the platform packages. Both required.
MaximumArguments Arguments accepted after the verb, 0 to 64. Default 0.
ArgumentPattern A .NET regular expression every argument after the verb must match completely, evaluated with RegexOptions.NonBacktracking.
Name The generated property name, when the last ID segment does not make a good one.
WindowsProgram Windows only: the elevated helper, as a path relative to the application's directory (such as helpers\example-helper.exe) or an absolute path. Default: the file name of Program with .exe, in the application's directory.
MacProgram macOS only: the helper, as a path relative to the application bundle (such as Contents/MacOS/example-helper) or an absolute path.
Commands under one action

The platform keeps an authorization per action: Polkit's auth_admin_keep retains it for the action ID. Several operations of one program that should share one authorization, such as activating a system and then collecting garbage, are commands of one action, declared as RunicPrivilegedCommand items. The first argument after the verb names the command, and each further argument must match the pattern at its position:

<ItemGroup>
  <RunicPrivilegedAction Include="org.example.app.system"
                         Program="/usr/libexec/example/example-helper"
                         Verb="system"
                         Description="Change the system"
                         Message="Authentication is required to change the system."
                         PolkitAllowActive="auth_admin_keep" />
  <RunicPrivilegedCommand Include="activate" Action="org.example.app.system"
                          Arguments="switch|boot;/nix/store/[a-z0-9]{32}-nixos-system-[A-Za-z0-9._+-]+" />
  <RunicPrivilegedCommand Include="rollback" Action="org.example.app.system" />
  <RunicPrivilegedCommand Include="gc" Action="org.example.app.system"
                          Arguments="[1-9][0-9]{0,2}" MinimumArguments="0" />
</ItemGroup>
Metadata Meaning
Item identity The command's name, the first argument after the verb. Unique within its action.
Action The ID of the RunicPrivilegedAction item it belongs to. Required.
Arguments One pattern per argument after the name, separated by ;, each matched completely like ArgumentPattern. Write \x3B for a semicolon inside a pattern. Default: no arguments.
MinimumArguments How many of those arguments are required; the rest may be left out from the end. Default: all.

An action with commands accepts only its commands. Its MaximumArguments defaults to the most arguments a command takes, counting the name; its ArgumentPattern, when set, still applies to every argument, the name included. The declaration carries the commands as PrivilegedActionDeclaration.Commands, so the application's request and the privileged program's PolkitHelper.Match (or ElevatedHelper.Match, or the macOS daemon) check the same positions; the program then switches on the command name. Invalid commands are build error RUNICPA015, and invalid command patterns RUNICPA008.

Platform packages read further metadata of their own, named after their platform, such as Polkit's PolkitIconName and PolkitAllowActive; see their guides. Metadata no package reads is ignored.

Each build validates the items and writes PrivilegedActions.g.cs under obj/.../runic-privileged/: an internal static partial class with one PrivilegedActionDeclaration property per action and All. The application and its privileged program both use it. Invalid items are build errors RUNICPA001 to RUNICPA009, RUNICPA011 and RUNICPA015, described in the diagnostics catalog.

Property Default
RunicPrivilegedActionsClassName PrivilegedActions
RunicPrivilegedActionsNamespace $(RootNamespace)
RunicPrivilegedActionsReservedNames All, MacOSDaemon and WindowsElevation, the property names platform packages generate in other parts of the class. An action's Name cannot be one of them.

The class name and namespace defaults are set when the package's targets are evaluated, after the project file, so a project's own RootNamespace applies and platform targets can read the final values.

Two targets are supported hooks: RunicValidatePrivilegedActions, which validates the items, and RunicGeneratePrivilegedActions, which writes the class. A project can run its own targets before or after them with BeforeTargets and AfterTargets. The names of the other privileged-action targets, in this package and in the platform packages, may change.

A platform package extends the generation with a target that depends on RunicValidatePrivilegedActions, which validates the items once, and appends that target to RunicPrivilegedActionsDependsOn; RunicGeneratePrivilegedActions writes the class only after those targets succeeded. A platform package that generates its own part of the class uses a reserved property name, and adds any new one to RunicPrivilegedActionsReservedNames. These two properties are the protocol between Runic.Platform and the platform packages.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (5)

Showing the top 5 NuGet packages that depend on Runic.Platform:

Package Downloads
Runic.Platform.Runtime

Single instance, requests to open files and URLs, and file transfers for Runic applications, and the contract for native platform providers.

Runic.Desktop

A managed .NET runtime for web-powered desktop applications.

Runic.Platform.Linux.DBus

D-Bus client and service API for Runic applications on Linux: connections, typed values, proxies, exported objects, signals and properties.

Runic.Application.Views.CsWebUi

CS-WebUI host adapter for Runic Window and View applications.

Runic.Platform.Linux.Polkit

Declared privileged actions for Runic applications on Linux: a generated Polkit policy and pkexec.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.12.0 0 10/27/2026
0.11.0 3,427 10/25/2026
0.10.0 6,794 10/22/2026
0.9.1 1,075 10/21/2026
0.9.0 287 10/21/2026
0.8.0-preview.6 1,665 10/19/2026
0.8.0-preview.5 484 10/17/2026
0.8.0-preview.4 1,045 10/17/2026
0.8.0-preview.3 924 10/15/2026
0.8.0-preview.2 180 10/15/2026
0.8.0-preview.1 232 10/15/2026
0.7.0-preview.9 619 10/14/2026
0.7.0-preview.8 144 10/12/2026
0.7.0-preview.7 90 10/12/2026
0.7.0-preview.6 1,255 10/9/2026
0.7.0-preview.5 207 10/9/2026
0.7.0-preview.4 114 10/8/2026
0.7.0-preview.3 256 10/8/2026
0.7.0-preview.2 108 10/7/2026
0.7.0-preview.1 606 10/7/2026
Loading failed