Background
What is the cleanest way to package a program? A single executable. But a binary affords the user no introspection into its workings.
What is the cleanest way to package code? A single source file.
What is the best programming language? Rust, of course :)
And what is the best way to provision a declarative toolchain? Nix, of course!
Why not integrate these axioms into one minimally hacky whole?
In short, a single Rust file with its entire toolchain and any and all dependencies declared inline, with the only external dependency being Nix itself. This would then be executable via a shebang, and would then run on desktop headfully, or be served over the web with a little preprocessing.
First Iteration
A simple Rust egui desktop app, with standard layout.
counter/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml:
[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = "0.36"
src/main.rs:
use eframe::egui;
struct Counter {
n: u32,
}
impl eframe::App for Counter {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.heading("Counter");
if ui.button("Increment").clicked() {
self.n += 1;
}
ui.label(format!("Count: {}", self.n));
}
}
fn main() -> eframe::Result {
eframe::run_native(
"Counter",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(Counter { n: 0 }))),
)
}
Runs normally via cargo run --release. This project is fine, but still requires a whole directory, manifest, and lockfile. A bit verbose, don't you think?
Second iteration
Slimming our app into a single file with scriptisto.
scriptisto enable a single source file to carry its own build recipe and files in frontmatter. Upon running, its build is cached. This eliminates the project directory.
counter.rs:
#!/usr/bin/env scriptisto
// scriptisto-begin
// script_src: src/main.rs
// build_cmd: cargo build --release && cp target/release/counter ./run
// target_bin: ./run
// files:
// - path: Cargo.toml
// content: |
// [package]
// name = "counter"
// version = "0.1.0"
// edition = "2021"
//
// [dependencies]
// eframe = "0.36"
// scriptisto-end
use eframe::egui;
struct Counter {
n: u32,
}
impl eframe::App for Counter {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.heading("Counter");
if ui.button("Increment").clicked() {
self.n += 1;
}
ui.label(format!("Count: {}", self.n));
}
}
fn main() -> eframe::Result {
eframe::run_native(
"Counter",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(Counter { n: 0 }))),
)
}
chmod +x counter.rs && ./counter.rs. The scriptisto shebang eliminates any external command, making it as simple as possible to actually execute the script. On running, the Cargo.toml is unpacked before the build in an ephemeral environment.
However, we still need preexisting in the environment the following: a Rust toolchain, a linker, and for egui, the right system libraries.
Third iteration
Pinning our toolchain
This will enable a reproducible run of the script on any machine with a nix flake. This devshell pins the compiler and necessary runtime libraries for winit.
flake.nix:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
libs = with pkgs; [ libGL libxkbcommon wayland ];
in {
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [ pkgs.cargo pkgs.rustc pkgs.scriptisto ] ++ libs;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath libs;
};
};
}
$ nix develop
$ ./counter.rs
The reproducible toolchain is great, but it's still a second file...
Fourth iteration
Shebang
Scriptisto's build step is just a shell command, so it can ask Nix for the environment itself. The shebang turns into nix shell nixpkgs#scriptisto -c scriptisto.
The build step evaluates a Nix expression with nix print-dev-env, saves the result to a bash file, and then builds inside that env. The run wrapper sources that file before launching, so Nix is evaluated only when the script changes and not every launch. Saving it with --profile also makes it immune to having its toolchain garbage collected. This is optional.
#!/usr/bin/env -S nix shell nixpkgs#scriptisto -c scriptisto
// scriptisto-begin
// script_src: src/main.rs
// target_bin: ./run
// build_cmd: |
// bash ./build.sh
// chmod +x run
// files:
// - path: build.sh
// content: |
// set -eu
// rm -f env env-*-link
// nix print-dev-env --impure --profile ./env --expr '
// with import <nixpkgs> {};
// mkShell {
// packages = [ cargo rustc ] ++ [ libGL libxkbcommon wayland ];
// LD_LIBRARY_PATH = lib.makeLibraryPath [ libGL libxkbcommon wayland ];
// }
// ' > env.raw
// grep -Ev '^export (NIX_BUILD_TOP|TMP|TMPDIR|TEMP|TEMPDIR)=' env.raw > env.sh
// bash -c '. ./env.sh && cargo build --release'
// - path: run
// content: |
// #!/usr/bin/env bash
// d="${0%/*}"
// . "$d/env.sh"
// exec "$d/target/release/counter" "$@"
// - path: Cargo.toml
// content: |
// [package]
// name = "counter"
// version = "0.1.0"
// edition = "2021"
//
// [dependencies]
// eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts", "x11", "wayland"] }
// scriptisto-end
use eframe::egui;
struct Counter {
n: u32,
}
impl eframe::App for Counter {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.heading("Counter");
if ui.button("Increment").clicked() {
self.n += 1;
}
ui.label(format!("Count: {}", self.n));
}
}
fn main() -> eframe::Result {
eframe::run_native(
"Counter",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(Counter { n: 0 }))),
)
}
We're back to one file, but this time with its own fully pinned and reproducible dependency recipe built in.
Fifth iteration
Web
What good is a program if you can't share it with friends?
Of course, some things can't be done in wasm, so instead of embedding the whole app in a page where it can't make arbitrary network requests, we'll serve it over the web, where the browser is a thin client, receiving egui updates similar to how one can forward X over ssh.
A builder reads the script's own scriptisto header, extracts its Cargo.toml, and adds a shim:
[patch.crates-io]
eframe = { path = "apps/remote/shim" }
This is a standin for eframe that implements just what is needed to stream events to and from a remote via websocket.
- Input events transit from the browser to the app as JSON
- egui's output is tesselated into meshes and texture updates and, of course, compressed before sending.
- The wasm thinclient paints the updates.
The script itself remains agnostic and unaware of this postprocessing.
Reasonable concurrency limits and queueing apply in order to ensure fair viewership on the site.
Demo
The above counter demo, running remotely rather than built to wasm.