Package a Runic application with Nix

On this page

Since SDK 0.8.0-preview.1, the Runic SDK's flake exports a builder for Runic applications, a development shell, and NixOS and Home Manager modules that install them. This guide follows 0.8.0-preview.5 and summarizes the SDK's Nix packaging guide, which has every argument and option.

buildRunicApplication wraps nixpkgs' buildDotnetModule. It restores NuGet packages and the frontend's packages from lock files, builds in the Nix sandbox without network access, and installs the desktop entry, icons and Polkit policy. It is verified on x86_64-linux, under JIT and NativeAOT. For working on the SDK itself, see NixOS development; for other platforms and Linux formats, see Ship a Desktop application.

Start from the template #

dotnet new runic-app --nix (or --nix with dnx Runic.Create) adds a flake.nix that uses the builder. It turns on RestorePackagesWithLockFile for linux-x64 and keeps a separate packages.aot.lock.json for NativeAOT. Write both lock files, commit them, and add the project to Git before the first build, because a flake sees only the files Git tracks:

dotnet restore
dotnet restore -p:PublishAot=true
# ...
git init && git add -A
nix build                # or nix build .#native-aot
result/bin/RunicWindowApp

With --tests, also run dotnet restore RunicWindowApp.Tests.

After changing a package reference, restore both again, or the build fails with NU1004. With pnpm or Bun, the frontend's packages are one fixed-output download whose hash in flake.nix starts as lib.fakeHash: the first nix build fails and reports the real hash. Replace it, and update it whenever the lock file changes.

A flake for an existing application #

Every function of runic-sdk.lib takes the consumer's nixpkgs first. Pin the SDK input to the release your packages use:

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  runic-sdk = {
    url = "github:Runic-Artifex/runic-sdk/v0.8.0-preview.5";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};
...
packages.x86_64-linux.default = runic-sdk.lib.buildRunicApplication pkgs {
  pname = "my-app";
  version = "1.0.0";
  src = ./.;
  projectFile = "MyApp/MyApp.csproj";
  nugetLockFiles = [ ./MyApp/packages.lock.json ];
  frontend = {
    directory = "MyApp/Frontend";
    packageManager = "npm";
  };
  nativeAot = true;
  executables = [ "MyApp" ];

The SDK guide's full example also installs a desktop entry and an icon. overlays.default adds pkgs.buildRunicApplication for package files that callPackage loads. Arguments of buildDotnetModule pass through; the Runic ones choose the host's run-time libraries (host: desktop, desktop-gtk4 or cswebui), NativeAOT, desktop items and icons.

Tests that reject VSTest options, such as TUnit's, run with testRunner:

testProjectFile = "MyApp.Tests/MyApp.Tests.csproj";
nugetLockFiles = [ ./MyApp/packages.lock.json ./MyApp.Tests/packages.lock.json ];
doCheck = true;
testRunner = "Microsoft.Testing.Platform";

completions installs the bash, zsh and fish scripts a command-line program prints:

meta.mainProgram = "my-app";
completions = { };                                # my-app completion bash, zsh, fish
completions.command = [ "--completions" ];        # my-app --completions bash, ...

NuGet and frontend packages #

nugetLockFiles reads the committed packages.lock.json files at evaluation time, and each locked package is a fixed-output download from nuget.org. The build restores in locked mode for linux-x64, so each lock file needs that runtime's section. A NativeAOT restore adds the compiler packages, so keep a second lock file for it, as the template does:

<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<RuntimeIdentifiers>linux-x64</RuntimeIdentifiers>
<NuGetLockFilePath Condition="'$(PublishAot)' == 'true'">packages.aot.lock.json</NuGetLockFilePath>

Packages from another feed, or a project without lock files, use nixpkgs' deps.json through nugetDeps.

dotnetTools restores local .NET tools offline, each by the hash of its package, for build steps that run one:

dotnetTools = {
  manifest = "MyApp/.config/dotnet-tools.json";
  hashes.dotnet-runic-translations = "sha256-/sG0ngRIx6fh0LLQzBNvpCk6N1kYB1NYhhKpI6NjDPY=";
};

The frontend's packages are installed in a separate derivation from its package.json and lock file only: npm through importNpmLock (no hash needed) or fetchNpmDeps, pnpm through fetchPnpmDeps, and Bun through bun install --frozen-lockfile in a fixed-output derivation. The build passes them to the Runic MSBuild targets with RunicApplicationFrontendNodeModulesDirectory, so the frontend builds without installing.

A stale Bun hash fails with frontend.hash is out of date, and in 0.8.0-preview.4 every Bun hash changes once: set it to lib.fakeHash and copy the reported hash. hash can also take one per system:

frontend = {
  packageManager = "bun";
  hash = {
    x86_64-linux = "sha256-...";
    aarch64-linux = lib.fakeHash;
  };
};

Run time and privileged actions #

GTK, WebKitGTK and GLib are loaded with dlopen, so they are run-time dependencies, never bundled. The program in bin is a binary wrapper that sets their library paths, schemas and GIO modules. Run the application through it: the program behind it finds no WebKitGTK, and Runic Desktop then reports webkitgtk-runtime-missing and opens a browser instead. The wrapper also sets RUNIC_APPLICATION_EXECUTABLE, so login startup writes the wrapper into the autostart entry.

withSpeechSynthesis = false loads a WebKitGTK without the Web Speech API's speech synthesis, 72.6 MiB smaller; screen readers still work, but it compiles from source for hours, so build it on your own cache.

The policy of the application's privileged actions is installed in $out/share/polkit-1/actions. The build exports RunicApplicationInstallPrefix as $out, so an action can name the installed program, and fails when a policy names a program outside the package:

<RunicPrivilegedAction Include="org.example.app.activate"
                       Program="$(RunicApplicationInstallPrefix)/bin/MyApp"
                       Verb="activate" ... />

NixOS and Home Manager modules #

nixosModules.runic-applications and homeManagerModules.runic-applications install applications as programs.runic.applications.<name>, with an optional systemd user service that starts the application with the graphical session, for example for an application with a status icon:

{
  imports = [ runic-sdk.nixosModules.runic-applications ];
  programs.runic.applications.my-app = {
    package = my-app.packages.x86_64-linux.default;
    userService = {
      enable = true;
      arguments = [ "--tray" ];
    };
  };
}

The NixOS module also registers the Polkit policy and enables pkexec. polkitd reads only the system's policies, so an application with privileged actions needs the package in the system profile, also with Home Manager. A launch from the desktop entry reaches the running instance when the application claims a single instance.

Development shell #

lib.mkDevShell gives a shell with the .NET SDK, Node, the package managers, Clang and zlib for NativeAOT, and the run-time environment of the chosen hosts:

devShells.x86_64-linux.default = runic-sdk.lib.mkDevShell pkgs {
  hosts = [ "desktop" ];
  packageManagers = [ "bun" ];
};

With the cswebui host the shell adds Chromium and sets CSWEBUI_BROWSER_PATH, so CS-WebUI windows open in that Chromium and fall back to no other browser. WebUI itself never read WEBUI_BROWSER_PATH. Run headless CS-WebUI in a shell without the Desktop libraries.

Checked with: SDK CI's nix flake check builds the status-icon example under JIT and NativeAOT and the privileged-actions smoke with its policy. A NixOS VM test installs them through the NixOS module and checks the desktop entry, the user service, pkaction and pkexec. The template's --nix projects are built for npm, pnpm, Bun and NativeAOT against a published release.