Since the sneak peek
A few weeks ago, I shared a first look at DotNative, a cross-platform UI framework I’m building in C#. This time I want to show the part I’ve spent most of my time on since: native plugins.
I’m opening up the framework, more than 30 plugin repositories, and the DotNative port of FaliNamIgrac, my app for organising sports games. The first public release is 0.1.0-beta.1, with working integrations and plenty still to do.
Firebase, Amazon Cognito and Google Mobile Ads are already part of that work. Below, I’ll use SecureStorage to show how a plugin is written, built and called from an app.
A quick look at the framework
I’ve spent a lot of time building apps with Unity, Flutter and React Native. I enjoy working in C#, but I missed the plugin workflow I had in those ecosystems.
Compose, SwiftUI and Flutter also suit the way I like to write UI: functions, expressions and components, all in the language I’m already using. I wanted to try that approach in C#.
DotNative has its own component and state model, with no XAML layer. Components describe a screen; when state changes, the runtime updates the affected native controls.
Those updates travel as binary commands to a Rust core. Taffy handles layout, and platform adapters create and update the controls. Some decoration is drawn by DotNative.
using DotNative;
public sealed class App : Component
{
private readonly State<int> _count = new(0);
public override Element Build() =>
new VStack(
new Text($"Count: {_count.Value}").FontSize(28),
new Button("Increment", () => _count.Value++)
).Padding(24);
}
public static class Program
{
public static void Main()
{
var builder = DotNativeApplication.CreateBuilder();
builder.Build().Run<App>();
}
}The example above includes the component, state and entry point. Application code stays in C#.
There are adapters for Android, iOS, macOS, Windows and Linux. They do not yet cover the same features.
NativeAOT by default, managed when you need it
Application builds default to NativeAOT unless the project selects another runtime. You can also package a managed .NET runtime with the app. The native renderer stays the same; the choice changes how your C# application and its dependencies execute.
dotnative run mac --project ./MyApp --runtime nativeaot
dotnative run mac --project ./MyApp --runtime managedOr make managed the project default:
<PropertyGroup>
<DotNativeRuntime>managed</DotNativeRuntime>
</PropertyGroup>The CLI option overrides DotNativeRuntime, which overrides an explicit PublishAot setting. Managed builds preserve assemblies for reflection-based libraries. They use CoreCLR on desktop and Mono on Android/iOS; iOS uses an interpreter for dynamic managed code, not unrestricted JIT. These deployment paths are experimental. Hot reload is a separate development mode.
Bringing native SDKs into C#
The plugin workflow was the part I most wanted to get right. I wanted to add a Kotlin or Swift integration, declare its dependencies and call it from C#, with the build taking care of the native files.
That now includes Firebase Analytics, Crashlytics, Cloud Messaging and Remote Config on Android and iOS. Cognito handles Hosted UI sign-in, PKCE and token exchange, as well as Cognito-backed AWS request signing.
These plugins use the existing SDKs and services. Firebase’s desktop implementation is still missing.
SecureStorage is a smaller example of the same approach. The following sections walk through its C# API, native implementation and build files.
Writing a real plugin: SecureStorage
Let’s build SecureStorage: C# API, Kotlin on Android, Swift on iOS, and C# interop on desktop.
Start with the CLI scaffold in an empty directory. The dotnative command requires the .NET 10 SDK; the CLI README covers installation from a local feed.
dotnative plugin new --name DotNative.SecureStorage --output ./DotNative.SecureStorageThe native scaffold creates implementations for all five platforms, plus a small example app:
DotNative.SecureStorage/
├── DotNative.SecureStorage.csproj
├── NativeService.cs # C# API and channel calls
├── Registration.cs # optional DI registration
├── Platform/
│ ├── Android/
│ │ └── DotNativeSecureStoragePlugin.kt
│ ├── iOS/
│ │ └── DotNativeSecureStoragePlugin.swift
│ ├── Mac/ # macOS
│ │ └── DotNativeSecureStoragePlugin.swift
│ ├── Windows/
│ │ └── DotNativeSecureStoragePlugin.cpp
│ └── Linux/
│ └── DotNativeSecureStoragePlugin.cpp
├── buildTransitive/
│ └── DotNative.SecureStorage.targets # native build inputs
├── example/ # app for trying the plugin
│ ├── DotNativeSecureStorageDemo.csproj
│ ├── Program.cs
│ ├── App.cs
│ └── Platform/ # native host projects
├── dotnative.local.props # local SDK path; gitignored
├── dotnative.local.props.example
├── README.md
└── .gitignoreStart with NativeService.cs for the shared API, then implement each platform in its own folder: Kotlin on Android, Swift on iOS/macOS, and C++ on Windows/Linux. The targets file tells the build which sources and entry point belong to each platform. Add SDK dependencies beside the relevant native files when needed.
These are starter implementations, not generated secure storage. The walkthrough below uses the finished SecureStorage plugin, whose entry point is named SecureStoragePlugin and whose desktop backends use C# interop. If you rename a generated entry point, update its build declarations too.
The bridge sends a method and named arguments over a channel. This shortened client uses the real channel; the native side selects the app’s storage automatically.
using DotNative.Plugins;
public sealed class StorageClient(IPlatformChannels channels)
{
private readonly MethodChannel channel = channels.Get("dotnative.secure-storage");
public Task WriteAsync(string key, string value) =>
channel.InvokeAsync("write", PluginArgs.Create(("key", key), ("value", value)));
public async Task<string?> ReadAsync(string key) =>
(string?)await channel.InvokeAsync("read",
PluginArgs.Create(("key", key)));
public Task DeleteAsync(string key) =>
channel.InvokeAsync("delete",
PluginArgs.Create(("key", key)));
}Kotlin and Swift register matching handlers. Results and errors return through the channel.
This teaching example omits validation, cancellation and typed error mapping present in the shipped plugin. Applications use the package’s ISecureStorage API.
PluginArgs.Create uses the existing binary codec, without reflection or JSON serialization.
Writing the Android plugin, file by file
Keep Kotlin sources and dependencies together in the plugin:
DotNative.SecureStorage/
└── Platform/
└── Android/
├── dependencies.gradle
└── SecureStoragePlugin.ktDeclare DataStore, Tink and coroutines here. Android Keystore comes from the OS.
dependencies {
implementation 'androidx.datastore:datastore-preferences:1.2.1'
implementation 'com.google.crypto.tink:tink-android:1.23.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
}The plugin uses coroutines directly, so it declares them. Gradle resolves shared dependencies rather than packaging a copy per plugin.
This is the complete current Kotlin file. NativeChannels comes from DotNative; the other imports come from Android and the dependencies above.
init registers handlers. They validate requests, work on Dispatchers.IO and reply on Main. Storage encrypts updates; companion object caches stores. Ordinary calls use the app’s package name.
Show complete Android plugin · 185 lines
package com.dotnative.plugins
import android.app.Activity
import android.content.Context
import android.util.AtomicFile
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.byteArrayPreferencesKey
import androidx.datastore.preferences.core.edit
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.TinkProtoKeysetFormat
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.aead.PredefinedAeadParameters
import com.google.crypto.tink.integration.android.AndroidKeystore
import java.io.File
import java.security.GeneralSecurityException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
class SecureStoragePlugin(activity: Activity) {
private val context = activity.applicationContext
init {
val channel = NativeChannels.channel("dotnative.secure-storage")
for (method in listOf("read", "write", "delete", "readAll", "deleteAll")) {
channel.handle(method) { arguments, reply ->
val fields = arguments as? Map<*, *>
val provided = fields?.get("applicationId") as? String
val namespace = provided?.takeIf { it.isNotEmpty() } ?: context.packageName
val key = fields?.get("key") as? String
val value = fields?.get("value") as? String
if (
namespace == null ||
!namespace.matches(Regex("[A-Za-z0-9_.-]{1,128}")) ||
namespace == "." ||
namespace == ".." ||
(method in listOf("read", "write", "delete") &&
(key.isNullOrBlank() || key.length > 1024 || key.contains('\u0000'))) ||
(method == "write" && (value == null || value.length > 4 * 1024 * 1024))
) {
reply.failure("invalid_argument", "Invalid secure-storage arguments")
return@handle
}
val job = scope.launch {
try {
val store = storage(context, namespace)
val result =
when (method) {
"read" -> store.read()[key]
"readAll" -> store.read()
"write" -> {
store.change { it[key!!] = value!! }
null
}
"delete" -> {
store.change { it.remove(key!!) }
null
}
else -> {
store.change { it.clear() }
null
}
}
withContext(Dispatchers.Main) { reply.success(result) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
withContext(Dispatchers.Main) {
reply.failure(
"secure_storage_unavailable",
"The protected credential store is unavailable. No plaintext fallback was used.",
)
}
}
}
reply.onCancel = { job.cancel() }
}
}
}
private class Storage(val data: DataStore<Preferences>, val aead: Aead, namespace: String) {
private val associatedData =
"dotnative.secure-storage/v1/$namespace".toByteArray(Charsets.UTF_8)
private fun decode(preferences: Preferences): MutableMap<String, String> {
val ciphertext = preferences[payload] ?: return mutableMapOf()
val json = JSONObject(String(aead.decrypt(ciphertext, associatedData), Charsets.UTF_8))
val result = mutableMapOf<String, String>()
for (key in json.keys()) {
val value = json.get(key)
require(value is String) { "Invalid secure-storage value" }
result[key] = value
}
return result
}
suspend fun read(): Map<String, String> = decode(data.data.first())
suspend fun change(update: (MutableMap<String, String>) -> Unit) {
data.edit { preferences ->
val values = decode(preferences)
update(values)
preferences[payload] =
aead.encrypt(
JSONObject(values as Map<*, *>).toString().toByteArray(Charsets.UTF_8),
associatedData,
)
}
}
}
companion object {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val payload = byteArrayPreferencesKey("payload")
private val stores = mutableMapOf<String, Storage>()
@Synchronized
private fun storage(context: Context, namespace: String): Storage =
stores.getOrPut(namespace) {
AeadConfig.register()
val directory = File(context.noBackupFilesDir, "dotnative-secure-storage")
check(directory.isDirectory || directory.mkdirs()) {
"Cannot create secure-storage directory"
}
val dataFile = File(directory, "$namespace.preferences_pb")
val keyset = AtomicFile(File(directory, "$namespace.keyset"))
val alias = "dotnative.secure-storage.$namespace"
val existingKeyset =
keyset.baseFile.exists() || File(directory, "$namespace.keyset.bak").exists()
if (!existingKeyset && dataFile.exists())
throw GeneralSecurityException("Encrypted storage exists without its keyset")
if (!AndroidKeystore.hasKey(alias)) {
if (existingKeyset)
throw GeneralSecurityException("The Keystore wrapping key is missing")
AndroidKeystore.generateNewAes256GcmKey(alias)
}
val master = AndroidKeystore.getAead(alias)
val keysetContext =
"dotnative.secure-storage/keyset/v1/$namespace".toByteArray(Charsets.UTF_8)
val handle =
if (existingKeyset) {
keyset.openRead().use {
TinkProtoKeysetFormat.parseEncryptedKeyset(
it.readBytes(),
master,
keysetContext,
RegistryConfiguration.get(),
)
}
} else {
val created = KeysetHandle.generateNew(PredefinedAeadParameters.AES256_GCM)
val output = keyset.startWrite()
try {
output.write(
TinkProtoKeysetFormat.serializeEncryptedKeyset(
created,
master,
keysetContext,
RegistryConfiguration.get(),
),
)
keyset.finishWrite(output)
} catch (error: Exception) {
keyset.failWrite(output)
throw error
}
created
}
Storage(
PreferenceDataStoreFactory.create(scope = scope, produceFile = { dataFile }),
handle.getPrimitive(RegistryConfiguration.get(), Aead::class.java),
namespace,
)
}
}
}For C# readers: val is read-only, init runs at construction, when is a switch expression, and companion object holds class-level members. suspend allows suspension; Dispatchers.IO selects background execution.
change(update) accepts a callback like Action<Dictionary<string, string>>. it is the map; !! asserts non-null after validation. The callback changes the decrypted map before DataStore saves its encrypted replacement.
Once the app references the plugin (shown in the usage section below), build the Android preview. On macOS, start an ARM64 emulator and replace the device ID as needed:
dotnative run android --project ./MyApp --hot-reload --device emulator-5554DotNative discovers the project’s DotNativePluginEntry and stages these files in the generated Android host:
Android/
└── app/
├── build.gradle # host build + plugin dependencies
├── src/main/java/com/dotnative/plugins/
│ ├── PluginChannels.kt # DotNative channel runtime
│ ├── SecureStoragePlugin.kt # copied plugin implementation
│ └── GeneratedPlugins.kt # generated registration
└── build/outputs/apk/debug/
└── app-debug.apk # Gradle outputThe generated app/build.gradle includes the plugin’s dependency block:
dependencies {
implementation 'androidx.datastore:datastore-preferences:1.2.1'
implementation 'com.google.crypto.tink:tink-android:1.23.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
}DotNative also generates registration:
package com.dotnative.plugins
import android.app.Activity
object GeneratedPlugins {
@JvmStatic fun register(activity: Activity) {
SecureStoragePlugin(activity)
}
}The CLI invokes Gradle below, then installs and launches the preview APK:
./gradlew --console=plain assembleDebugC# calls now reach the registered handlers. Encrypted data and keysets stay under noBackupFilesDir; this backend supports one process per namespace.
iOS: Swift and Keychain
Swift registers the same channel. A KeychainWorker actor serializes storage work away from the UI thread.
Entries use the app’s service scope, disable iCloud sync and use AfterFirstUnlockThisDeviceOnly. Each operation has its own method; here is write:
import Foundation
import Security
extension KeychainWorker {
func write(_ namespace: String, key: String, value: String) -> KeychainResult {
if Task.isCancelled { return .empty }
var query = query(namespace, key: key)
var status: OSStatus = errSecSuccess
let updates: [String: Any] = [
kSecValueData as String: Data(value.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
status = SecItemUpdate(query as CFDictionary, updates as CFDictionary)
if status == errSecItemNotFound {
query.merge(updates) { _, new in new }
status = SecItemAdd(query as CFDictionary, nil)
// Another process may have created the item between update and add.
if status == errSecDuplicateItem {
query.removeValue(forKey: kSecValueData as String)
query.removeValue(forKey: kSecAttrAccessible as String)
status = SecItemUpdate(query as CFDictionary, updates as CFDictionary)
}
}
return status == errSecSuccess ? .empty : .failure(status)
}
}Update first, insert if missing, and handle a competing insert. Failures preserve OSStatus.
Cancellation cannot undo an in-progress Keychain write. Entries may survive uninstall; Android storage has different lifecycle behavior.
The build compiles these Swift files and generates the registration call. SecureStorage links Apple’s built-in Security framework; plugins with third-party binary SDKs declare them in apple-dependencies.json so the build can verify, link and package them.
Desktop: the same API, C# interop
macOS uses Keychain through C# interop. Windows uses Credential Manager; Linux uses libsecret with a Secret Service provider such as GNOME Keyring or KWallet.
These backends call OS APIs directly from C#:
internal static string? Read(string app, string key)
{
if (!CredReadW(Prefix + app + "/" + key, 1, 0, out var ptr))
{
if (Marshal.GetLastWin32Error() == 1168)
return null;
Throw("Credential Manager read");
}
try
{
return Decode(Marshal.PtrToStructure<Credential>(ptr));
}
finally
{
CredFree(ptr);
}
}The Windows backend frees the native buffer in finally and distinguishes a missing entry from an OS error.
Windows values are limited to 2,560 UTF-8 bytes. Linux needs an available, unlocked Secret Service. No backend falls back to plaintext.
Register it, package it, test it
The plugin declares its native files and entry point in buildTransitive targets. AddSecureStorage is the separate, optional registration for C# dependency injection:
<Project>
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'ios-arm64' or '$(RuntimeIdentifier)' == 'iossimulator-arm64'">
<DotNativeNativeSource Include="$(MSBuildThisFileDirectory)../Platform/iOS/*.swift" />
<DotNativeNativeFramework Include="Security" />
<DotNativePluginEntry Include="SecureStoragePlugin" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-bionic-arm64' or '$(RuntimeIdentifier)' == 'android-arm64' or '$(RuntimeIdentifier)' == 'android-x64'">
<DotNativeNativeSource Include="$(MSBuildThisFileDirectory)../Platform/Android/*.kt" />
<DotNativeAndroidGradle Include="$(MSBuildThisFileDirectory)../Platform/Android/dependencies.gradle" />
<DotNativePluginEntry Include="SecureStoragePlugin" />
</ItemGroup>
</Project>NuGet imports these targets for the app. When referencing a source project, import the plugin targets explicitly for publish. DotNative then compiles the native sources and generates registration.
The plugin scaffold creates this structure for a new integration. Replace its sample operation, add the native dependencies, and test it from a small app.
For storage, that means checking restarts, missing and empty values, isolation, deletion, cancellation and unavailable OS services—not just writing and reading a value once.
Keep a plugin inside your app
Plugins can live inside the app—no separate repository or package required. Generated apps include Android Gradle and iOS Xcode projects.
MyApp/
├── MyApp.csproj
├── StorageClient.cs
├── NativePlugins/
│ ├── Android/
│ │ ├── SecureStoragePlugin.kt
│ │ └── dependencies.gradle
│ └── iOS/
│ └── *.swift # plugin, request and Keychain worker files
└── Platform/
├── Android/ # open this Gradle project in Android Studio
└── iOS/Runner.xcodeproj # open in XcodeAdd these items to the app .csproj and keep channel names consistent. Use the local implementation or the package, not both:
<!-- Inside your application .csproj; no separate plugin package needed. -->
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'ios-arm64' or '$(RuntimeIdentifier)' == 'iossimulator-arm64'">
<DotNativeNativeSource Include="NativePlugins/iOS/*.swift" />
<DotNativeNativeFramework Include="Security" />
<DotNativePluginEntry Include="SecureStoragePlugin" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-bionic-arm64' or '$(RuntimeIdentifier)' == 'android-arm64' or '$(RuntimeIdentifier)' == 'android-x64'">
<DotNativeNativeSource Include="NativePlugins/Android/*.kt" />
<DotNativeAndroidGradle Include="NativePlugins/Android/dependencies.gradle" />
<DotNativePluginEntry Include="SecureStoragePlugin" />
</ItemGroup>These MSBuild publish inputs stage sources, dependencies and registration. The preview CLI uses a separate conventional-folder discovery path.
Open Platform/Android in Android Studio or Runner.xcodeproj in Xcode. Run the DotNative build first to prepare generated inputs. Edit owned sources, not files under obj.
Using the finished plugin in a login flow
Now add the finished plugin to your application. With MyApp and DotNative.SecureStorage in sibling directories, run:
dotnet add ./MyApp/MyApp.csproj reference ./DotNative.SecureStorage/DotNative.SecureStorage.csprojThis adds the project reference below. The preview CLI discovers the referenced plugin; for the MSBuild publish path, also import its native build targets:
<ItemGroup>
<ProjectReference Include="../DotNative.SecureStorage/DotNative.SecureStorage.csproj" />
</ItemGroup>
<Import Project="../DotNative.SecureStorage/buildTransitive/DotNative.SecureStorage.targets" />A published NuGet package is the alternative: its buildTransitive targets are imported automatically. The examples below use the current source version, including the new static API.
Here is a login service using the finished plugin. Replace the example endpoint and response type with your backend’s contract.
using DotNative.SecureStorage;
using Microsoft.Extensions.DependencyInjection;
// In Program.Main, before builder.Build():
builder.Services.AddSecureStorage();
builder.Services.AddSingleton(new HttpClient
{
BaseAddress = new Uri("https://api.example.com/")
});
builder.Services.AddTransient<LoginFlow>();The constructor receives HttpClient and ISecureStorage. The token is saved only after the server accepts the login:
using System.Net.Http.Json;
using DotNative.SecureStorage;
public sealed class LoginFlow(HttpClient api, ISecureStorage storage)
{
public async Task LoginAsync(string email, string password, CancellationToken ct = default)
{
using var response = await api.PostAsJsonAsync("auth/login", new { email, password }, ct);
response.EnsureSuccessStatusCode();
var session = await response.Content.ReadFromJsonAsync<LoginResponse>(ct);
if (string.IsNullOrWhiteSpace(session?.RefreshToken))
throw new InvalidDataException("Login response is missing a refresh token.");
// Save only after the backend has accepted the login.
await storage.WriteAsync("refresh-token", session.RefreshToken, ct);
}
public Task<string?> ReadSavedTokenAsync(CancellationToken ct = default) =>
storage.ReadAsync("refresh-token", ct);
public Task ClearLocalSessionAsync(CancellationToken ct = default) =>
storage.DeleteAsync("refresh-token", ct);
}
public sealed record LoginResponse(string RefreshToken);// login is the LoginFlow injected into the screen.
await login.LoginAsync(email, password);
// Navigate to the signed-in screen after this completes successfully.On the next launch, ReadSavedTokenAsync supplies the token for session renewal. ClearLocalSessionAsync removes it at sign-out; server-side revocation is a separate API call.
You can also construct the service yourself. SecureStorage.Create() uses the current runtime and needs no AddSecureStorage registration:
var storage = SecureStorage.Create();
var login = new LoginFlow(api, storage);
await login.LoginAsync(email, password);Or call the static API at the appropriate point in each flow:
// After a successful login:
await SecureStorage.WriteAsync("refresh-token", refreshToken);
// On a later app launch:
var savedToken = await SecureStorage.ReadAsync("refresh-token");
// During sign-out:
await SecureStorage.DeleteAsync("refresh-token");Static calls use the current device and session, including remote preview. They work during startup, UI/lifecycle callbacks and their async continuations, and fail outside that context or after shutdown. They use the default namespace, not a custom DI registration. Pass an explicit service to independent threads; use DI when you want to substitute storage in tests.
You can write the plugin in .NET too
You can keep a plugin in C# when that suits the integration. SecureStorage’s desktop backends already do this through OS interop.
Use --kind csharp to write the platform implementation in C#. This generates a shared partial API and a C# file for each platform; the app chooses its runtime separately.
dotnative plugin new --name Acme.Storage --kind csharp --output ./Acme.Storagedotnative run ios --bindings --project ./MyApp --build-onlyDirect platform bindings require the matching .NET workloads and toolchains. They run on the target platform; a Mac preview process cannot invoke Android bindings locally. Android NativeAOT bindings remain experimental.
Using it in FaliNamIgrac
I’m using the plugins in a DotNative port of FaliNamIgrac, my app for organising sports games. It has authentication, profiles, game bookings, chat, notifications and ads.
Working on the port exposed issues the samples didn’t: initialization order, responses arriving after logout, and several plugins needing the same native callback.
The port is unfinished and needs its own backend configuration.
There are more than 30 plugin repositories, including native integrations, managed libraries and tooling. Their README files describe platform support and verification.
Where MAUI, Avalonia and Uno fit
MAUI, Avalonia and Uno already support native interop. They also offer ways to write UI in C#, through MAUI’s Community Toolkit Markup, Uno’s C# Markup and Avalonia’s community declarative library.
The workflow I wanted was more specific: keep the Kotlin or Swift integration beside its C# API, declare its SDK dependencies, and let the build put it together. DotNative apps also include editable Android Gradle and iOS Xcode host projects. I can open either project and work in the platform’s own tools.
I’d like to make the plugin system usable from MAUI, Avalonia and Uno as well. Those adapters don’t exist yet. Each host needs to supply its dispatcher, lifecycle and build integration; plugins that embed native views need additional work.
If you maintain plugins for one of those frameworks, I’d be interested in what you would need from that integration.
Try the preview
Start with Counter, rather than the showcase app. On an Apple Silicon Mac with Xcode, .NET SDKs 9 and 10, and Rust 1.94.0 installed, the source quickstart is:
git clone https://github.com/kantagara/DotNative.git
cd DotNative
rustup override set 1.94.0
dotnet run --project tools/DotNative.Cli -- run mac --hot-reload --project Samples/CounterI checked that path from an isolated checkout: the window opened, Increment and Reset worked, and a saved C# text edit appeared through hot reload. Native SDK fixture builds also checked device and simulator linking, resources and signatures. Those checks are useful; they don’t substitute for live push delivery, two-account chat testing or a signed store release.
If you maintain native integrations in .NET, I’d like your feedback on the channel contract and build setup. What would you need from an adapter before you could use one of these plugins in your own framework?