Runic.Platform
0.9.1
See the version list below for details.
dotnet add package Runic.Platform --version 0.9.1
NuGet\Install-Package Runic.Platform -Version 0.9.1
<PackageReference Include="Runic.Platform" Version="0.9.1" />
<PackageVersion Include="Runic.Platform" Version="0.9.1" />
<PackageReference Include="Runic.Platform" />
paket add Runic.Platform --version 0.9.1
#r "nuget: Runic.Platform, 0.9.1"
#:package Runic.Platform@0.9.1
#addin nuget:?package=Runic.Platform&version=0.9.1
#tool nuget:?package=Runic.Platform&version=0.9.1
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 */ }
}
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. 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.
| 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); no filter pop-up |
FileChooser filters and current_filter |
File type list |
| Multiple selection | FOS_ALLOWMULTISELECT |
allowsMultipleSelection |
FileChooser multiple |
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 to a name the user types without one. Applications that need the user to pick between filters on macOS can offer the choice in their own interface for now.
Window services
IWindowPlatformServices groups the file dialogs, text 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, IDesktopFileLauncher 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.
Opening links
IDesktopFileLauncher.LaunchUriAsync(uri) opens a web or mail link in the
user's default browser or mail client and reports the outcome as a
PlatformResult (capability platform.uris.launch):
var result = await launcher.LaunchUriAsync(new Uri("https://example.org/help"));
It accepts only absolute http, https and mailto 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. The URI comes from
trusted application code: validate links a web page supplies before passing them
on.
| 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.
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.
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, IDesktopFileLauncher |
Native provider; OwnerClosed after the window closes |
Unavailable(OwnerUnavailable) |
Unavailable(OwnerUnavailable) |
Announcements 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, once per drop | None | None |
Menu bar commands, IRunicMenuCommandSource |
The macOS menu bar, while the window is key | None | None |
IDesktopSettings, IDesktopNotifications, IDesktopStatusIcon, IDesktopLoginStartup, IDesktopInhibition, IDesktopSecretStore, IDesktopTrash, IDesktopFileTransfers, IDesktopVolumes, 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.
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.
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. Secrets are UTF-8
text of at most 2560 bytes on every platform; a longer one is
Failed(TooLarge). 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.
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).
| Platform | Provider |
|---|---|
| Linux | UDisks2 on the system bus, which asks Polkit; inside Flatpak add --system-talk-name=org.freedesktop.UDisks2 |
| 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" |
| 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 |
Hosts without a provider register DesktopVolumes.Unavailable(reason). The
volumes design
has the per-platform behaviour and its verification.
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. 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 | 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
- 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.11.0 | 2,113 | 10/25/2026 |
| 0.10.0 | 6,775 | 10/22/2026 |
| 0.9.1 | 1,065 | 10/21/2026 |
| 0.9.0 | 277 | 10/21/2026 |
| 0.8.0-preview.6 | 1,665 | 10/19/2026 |
| 0.8.0-preview.5 | 483 | 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 | 231 | 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 | 87 | 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 |
| 0.6.0-preview.1 | 857 | 10/5/2026 |