This guide walks you through creating your first Ryn desktop application -- from installing the CLI to bundling for distribution.
Required:
- .NET 10 SDK (preview or later)
Platform-specific:
| Platform | Requirement |
|---|---|
| macOS | Xcode Command Line Tools (WKWebView is built-in) |
| Windows | WebView2 Runtime (bundled with Windows 11, separate install on Windows 10) |
| Linux | libwebkitgtk-6.0-dev -- install with sudo apt-get install libwebkitgtk-6.0-dev on Ubuntu/Debian |
Verify your .NET SDK version:
dotnet --version
# Should output 10.0.x or higherInstall the CLI:
dotnet tool install -g Ryn.CliVerify:
ryn --help
ryn doctorIf you want to build Ryn itself or contribute:
git clone --recursive https://github.com/Yupmoh/Ryn.git
cd Ryn
bash build/download-native.sh # macOS/Linux (or .\build\download-native.ps1 on Windows)
dotnet build Ryn.slnx
dotnet test Ryn.slnxWhen working from source, the CLI is available via dotnet run --project src/Ryn.Cli --. For example: dotnet run --project src/Ryn.Cli -- new MyApp. Projects created from within the source tree automatically use project references instead of NuGet packages.
ryn new MyAppThis scaffolds a complete Ryn project with IPC commands, a dark-themed HTML frontend, and a capability file. You will see output like:
Creating Ryn project 'MyApp'...
Using NuGet package references
Created project files
Restoring packages...
Project 'MyApp' created successfully!
cd MyApp
ryn dev
Project names must start with a letter and contain only letters, digits, and underscores.
If you prefer a Vite + TypeScript frontend instead of plain HTML:
ryn new MyApp --viteThis creates an additional frontend/ directory with a Vite project, TypeScript config, and typed window.__ryn declarations.
After scaffolding, your project looks like this:
MyApp/
MyApp.csproj -- Project file with Ryn package references
Program.cs -- Entry point: creates the app builder, configures options, runs the app
Commands.cs -- Your [RynCommand] methods (C# backend logic callable from JS)
appsettings.json -- Window title, size, and logging config
ryn.json -- Capability-based security (controls what JS can access)
wwwroot/
index.html -- Your frontend (HTML/CSS/JS)
Program.cs -- Configures and launches the application:
using Ryn.Core;
using Ryn.Ipc;
using MyApp;
public static class Program
{
[System.STAThread]
public static void Main()
{
var app = RynApplication.CreateBuilder()
.ConfigureOptions(opts =>
{
opts.ContentDirectory = Path.Combine(AppContext.BaseDirectory, "wwwroot");
})
.ConfigureServices(services =>
{
services.AddRynCommands(); // Registers the IPC dispatcher
services.AddAppCommands(); // Registers your [RynCommand] methods (source-generated)
})
.Build();
app.Run();
}
}Commands.cs -- Your backend logic, exposed to JavaScript via [RynCommand]:
using System.Globalization;
using Ryn.Ipc;
namespace MyApp;
public static class AppCommands
{
[RynCommand("app.greet")]
public static string Greet(string name) => $"Hello, {name}!";
[RynCommand("app.add")]
public static int Add(int a, int b) => a + b;
[RynCommand("app.getTime")]
public static string GetTime() => DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
}Command names are plugin-prefixed: your own commands live under app.*, and each plugin owns its own prefix (fs.*, clipboard.*, ...). The prefix is what ryn.json capabilities grant or deny.
appsettings.json -- Window and logging configuration:
{
"Ryn": {
"Title": "MyApp",
"Width": 900,
"Height": 700,
"DevTools": true
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}ryn.json -- Security: controls which plugins and commands the frontend can invoke. The scaffolded file grants only your own app.* commands:
{
"capabilities": {
"app": true
}
}Each plugin is granted by its prefix. A fuller example that also enables a couple of plugins:
{
"capabilities": {
"app": true,
"fs": {
"allow": ["readTextFile", "readDir", "exists", "stat"]
},
"clipboard": true,
"notification": true
}
}When ryn.json is present, all commands are denied by default unless explicitly allowed. When it is absent, the fallback depends on the build: a Debug build allows everything (dev convenience), while a Release build fails closed and denies every command (logging a one-time startup warning). Always ship a ryn.json with your app — see SECURITY.md for the full capability model.
cd MyApp
ryn devDev mode does three things:
- Builds the project
- Launches the app window
- Watches for file changes:
- C# file changes (
.cs) trigger a full rebuild and relaunch - Frontend file changes (
wwwroot/) sync to the output directory and relaunch without rebuilding
- C# file changes (
For a Vite project, ryn dev also auto-starts the Vite dev server (npm run dev) and points the webview at it — running npm run dev yourself in a second terminal is optional now (if a server is already listening on the Vite port, ryn dev reuses it). See the Vite Integration Guide.
Press Ctrl+C to stop.
During development, you can also use standard dotnet run:
dotnet runIPC (inter-process communication) lets your JavaScript frontend call C# methods and get results back.
Add a method to Commands.cs with the [RynCommand] attribute. Give it an app.* name so it falls under the "app" capability:
[RynCommand("app.getFruits")]
public static string[] GetFruits() => ["Apple", "Banana", "Cherry"];
[RynCommand("app.fetchData")]
public static async ValueTask<string> FetchData(string url, CancellationToken cancellationToken)
{
using var client = new HttpClient();
return await client.GetStringAsync(url, cancellationToken);
}The source generator automatically creates a router and DI registration at compile time. Supported parameter and return types:
- Primitives:
int,long,float,double,bool,string - Arrays:
int[],string[], etc. - Nullable:
int?,bool?, etc. JsonElement(for manual deserialization of complex types)CancellationToken(must be the last parameter, auto-wired)voidandValueTaskreturns (for fire-and-forget commands)- Custom DTOs via
[RynJsonContext]and STJ source generation
Call the command from your frontend using window.__ryn.invoke():
// Simple call
const greeting = await window.__ryn.invoke('app.greet', { name: 'World' });
// Array return
const fruits = await window.__ryn.invoke('app.getFruits', {});
// Async call
const data = await window.__ryn.invoke('app.fetchData', { url: 'https://api.example.com/data' });The name you pass to [RynCommand(...)] is exactly the name JavaScript invokes. The prefix (app here) is what ryn.json capabilities grant or deny, so keep your own commands under app.*. Parameters are passed as a JSON object whose keys match the C# parameter names (camelCase).
You can use any prefixed name you like:
[RynCommand("app.doSomething")]
public static string DoSomething() => "done";const result = await window.__ryn.invoke('app.doSomething', {});Ryn also supports events from C# to JavaScript:
// C# side: emit an event.
// Preferred — strongly typed, serialized via source-generated JsonTypeInfo (AOT- and injection-safe):
webView.EmitEvent("dataUpdated", new Update(42), AppJsonContext.Default.Update);
// String overload — the payload MUST be valid JSON; it is validated/canonicalized to prevent
// script injection. An invalid JSON string throws ArgumentException.
webView.EmitEvent("dataUpdated", "{\"count\": 42}");// JS side: listen for events
window.__ryn.on('dataUpdated', (data) => {
console.log('Data updated:', data.count);
});
// Unsubscribe
window.__ryn.off('dataUpdated', handler);Ryn includes built-in plugins for common native operations. Here is how to add the Clipboard plugin.
dotnet add package Ryn.Plugins.Clipboardusing Ryn.Plugins.Clipboard;
var app = RynApplication.CreateBuilder()
.ConfigureOptions(opts =>
{
opts.ContentDirectory = Path.Combine(AppContext.BaseDirectory, "wwwroot");
})
.ConfigureServices(services =>
{
services.AddRynCommands();
services.AddAppCommands();
services.AddRynClipboard(); // Add the clipboard plugin
})
.Build();
app.Run();Add clipboard to your capabilities:
{
"capabilities": {
"clipboard": true
}
}// Write to clipboard
await window.__ryn.invoke('clipboard.writeText', { text: 'Hello from Ryn!' });
// Read from clipboard
const text = await window.__ryn.invoke('clipboard.readText', {});
// Check if clipboard has text
const hasText = await window.__ryn.invoke('clipboard.hasText', {});Zoom the host UI without legacy CSS-zoom coordinate mismatches:
await window.__ryn.invoke('window.setPageZoom', { factor: 0.8 });
const factor = await window.__ryn.invoke('window.getPageZoom');The factor is clamped to 0.25–5.0. Title-bar drag regions and webviewPane.setBounds continue to
use page CSS pixels; Ryn converts them to native coordinates automatically.
| Plugin | Package | Registration | Commands |
|---|---|---|---|
| FileSystem | Ryn.Plugins.FileSystem |
AddRynFileSystem(opts => ...) |
fs.readTextFile, fs.writeTextFile, fs.readDir, fs.stat, fs.exists, fs.mkdir, fs.remove |
| Dialog | Ryn.Plugins.Dialog |
AddRynDialog() |
dialog.message, dialog.confirm, dialog.openFile, dialog.openFiles, dialog.openFolder, dialog.save, plus secure picker commands below |
| Clipboard | Ryn.Plugins.Clipboard |
AddRynClipboard() |
clipboard.readText, clipboard.writeText, clipboard.hasText, clipboard.clear |
| Shell | Ryn.Plugins.Shell |
AddRynShell(opts => ...) |
shell.execute, shell.open, shell.spawn, shell.kill, shell.pty, shell.ptyWrite, shell.ptyResize, shell.ptyMetrics, shell.ptyKill |
| Notification | Ryn.Plugins.Notification |
AddRynNotification() |
notification.send, notification.sendWithSound, notification.sendWithIcon, notification.sendWithId, notification.isSupported, notification.isPermissionGranted, notification.requestPermission |
| Audio | Ryn.Plugins.Audio |
AddRynAudio() |
audio.play, audio.playSystem, audio.stop, audio.setVolume, audio.isPlaying |
| Tray | Ryn.Plugins.Tray |
AddRynTray(opts => ...) |
tray.show, tray.hide, tray.setTooltip, tray.setMenu, tray.notify |
| MenuBar | Ryn.Plugins.MenuBar |
AddRynMenuBar(opts => ...) |
menubar.setMenu, menubar.reset |
| Badge | Ryn.Plugins.Badge |
AddRynBadge() |
badge.set, badge.setCount, badge.clear |
| GlobalShortcut | Ryn.Plugins.GlobalShortcut |
AddRynGlobalShortcut() |
globalShortcut.register, globalShortcut.unregister, globalShortcut.isRegistered, globalShortcut.unregisterAll |
| WebViewPane | Ryn.Plugins.WebViewPane |
AddRynWebViewPane() |
webviewPane.open, webviewPane.close, webviewPane.setBounds, webviewPane.navigate, webviewPane.reload, webviewPane.resolvePermission, webviewPane.resolveDownload, webviewPane.screenshot, webviewPane.execute, webviewPane.eval, webviewPane.list |
| Updater | Ryn.Plugins.Updater |
AddRynUpdater(opts => ...) |
updater.check, updater.download, updater.apply |
The picker commands accept an options object with Title, Filters (each filter has Name and Extensions), Multiple, InitialPath, and SuggestedFileName (save only). dialog.openFile, dialog.openFolder, and dialog.save return one path; dialog.openFiles returns a JSON array. All return null on cancellation and reject when the native dialog fails. InitialPath is best-effort.
For a scoped filesystem capability, use dialog.openFileSecure, dialog.openFilesSecure, dialog.openFolderSecure, or dialog.saveSecure. These return opaque ryn-grant-... tokens rather than native paths; the corresponding grants authorize read, enumerate, or create/write access for the selected entry. Resolve tokens in host code through IFileAccessGrants. The browser FileDrop event intentionally exposes names only, not native paths, so browser drops cannot produce secure filesystem grants.
Plugins that access the filesystem or shell require configuration for safety:
services.AddRynFileSystem(fs =>
fs.AllowedPaths.Add(AppContext.BaseDirectory));
services.AddRynShell(shell =>
shell.AllowedCommands.AddRange(["echo", "git", "ls"]));IRynPaths is available from DI for stable absolute directories such as LocalAppData, RoamingAppData, Documents, Cache, Temp, ResourceDirectory, and InstallDirectory:
public sealed class PathsService(IRynPaths paths)
{
public string CacheDirectory => paths.Cache;
}
shell.ptyplatform support. The PTY commands use ConPTY on Windows (Windows 10 1809+) and a nativeryn-ptyshim on macOS and Linux. If that native shim is not present next to the application,shell.ptythrows a clearPlatformNotSupportedExceptionrather than falling back to an unsafe path. The non-PTYshell.execute/shell.open/shell.spawncommands work on all three platforms.
ryn buildThis runs dotnet publish -c Release and outputs the result to bin/Release/net10.0/publish/.
ryn build --aotNativeAOT produces a single native binary with no .NET runtime dependency. A hello-world app is around 5.0 MB on macOS arm64; a full app pulling in every plugin is around 5.6 MB. Ryn is designed NativeAOT-first -- no reflection is used anywhere. JSON serialization uses source-generated JsonSerializerContext, and IPC routing uses a source-generated switch-based dispatch table.
ryn build --embedBundles your wwwroot/ directory into the binary for single-file distribution.
ryn bundleThis builds a release and creates a platform-appropriate distributable:
| Platform | Output | Location |
|---|---|---|
| macOS | .app bundle with Info.plist |
bin/bundle/MyApp.app |
| Windows | Folder with executable + WiX .wxs for MSI |
bin/bundle/MyApp/ |
| Linux | AppDir structure (ready for appimagetool) |
bin/bundle/MyApp.AppDir/ |
ryn bundle --aot # NativeAOT publish
ryn bundle --self-contained # Include .NET runtime
ryn bundle --icon path/to/icon.png # Override the app icon (PNG auto-converted to .icns/.ico)
ryn bundle --sign "Developer ID" # Code sign (macOS)
ryn bundle --notarize # Submit for Apple notarization (macOS)
ryn bundle --version 1.0.0 # Set bundle versionIf you don't pass --icon (or set bundle.icon in ryn.json), the bundle is branded with the Ryn default icon — a real AppIcon.icns on macOS, an .ico on Windows, and a hicolor PNG on Linux. At runtime every window also uses the Ryn icon by default; override it with RynOptions.IconPath.
You can also configure bundle metadata in ryn.json:
{
"capabilities": { ... },
"bundle": {
"identifier": "com.example.myapp",
"version": "1.0.0",
"icon": "assets/icon.png"
}
}After bundling, the output directory contains a generated WiX .wxs file:
dotnet tool install --global wix
cd bin\bundle\MyApp
wix build MyApp.wxs -o MyApp.msiIf appimagetool is in your PATH, ryn bundle builds the AppImage automatically. Otherwise:
bash bin/bundle/build-appimage.shRyn supports three ways to provide frontend content:
// Option 1: ContentDirectory -- serve files from disk (recommended for most apps)
opts.ContentDirectory = Path.Combine(AppContext.BaseDirectory, "wwwroot");
// Option 2: Html -- inline HTML string (good for simple tools or generated UI)
opts.Html = "<html><body><h1>Hello</h1></body></html>";
// Option 3: Url -- external URL (for Vite/webpack dev server integration)
opts.Url = new Uri("http://localhost:5173");With ContentDirectory, files are served through the ryn:// custom scheme, keeping IPC same-origin. Built-in file I/O runs off the scheme callback thread. Custom and built-in file ranges return only the selected bytes (206) and bound materialized memory for partial loads; Saucer's contiguous stash means the selected response or range is materialized before native acceptance rather than streamed zero-copy. Changes to files on disk are reflected on browser refresh without restarting the app.
On Windows, the entry point must use [STAThread] with a synchronous Main method. Without it, WebView2 initialization deadlocks silently.
public static class Program
{
[System.STAThread]
public static void Main()
{
var app = RynApplication.CreateBuilder()
// ...
.Build();
app.Run(); // Synchronous -- blocks until window closes
}
}Do not use async Task Main or top-level statements on Windows -- both default to MTA, which is incompatible with WebView2's COM requirements. On macOS and Linux, await app.RunAsync() and top-level statements work fine.
- Plugin Authoring Guide -- Create your own Ryn plugins with commands, options, DI, and capability scopes
- Vite Integration Guide -- Use Vite or other JS bundlers as your frontend toolchain