diff --git a/.gitignore b/.gitignore
index 823f9276..accaa0de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ plan/
.DS_Store
.claude/
/craft-fonts
+# nix build / nix develop
+/result
+/result-*
diff --git a/AGENTS.md b/AGENTS.md
index 393db2b1..d3bc859c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
-photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
+photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
+`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
+desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Keep it building
+when the workspace layout, `Cargo.lock`, the packaging files or the fonts change; see README → Quick start.
diff --git a/CLAUDE.md b/CLAUDE.md
index 393db2b1..d3bc859c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -119,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
-photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
+photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
+`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
+desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Keep it building
+when the workspace layout, `Cargo.lock`, the packaging files or the fonts change; see README → Quick start.
diff --git a/README.md b/README.md
index 5e741b7c..afa40967 100644
--- a/README.md
+++ b/README.md
@@ -347,6 +347,31 @@ repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftru
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
+**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
+text has glyphs):
+
+```sh
+nix run github:storytold/lightcraft # the desktop app
+nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
+nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
+```
+
+In a flake configuration (NixOS, home-manager, nix-darwin):
+
+```nix
+# flake.nix
+inputs.lightcraft.url = "github:storytold/lightcraft";
+# optional: build against your own nixpkgs instead of the one LightCraft pins
+# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
+
+# then, in a NixOS or home-manager module (where `inputs` is in scope):
+nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
+environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
+```
+
+`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
+`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
+
**Keyboard:** G grid · D detail · E edit · C crop · M masking ·
Shift+P presets · \\ original · Y before/after · Z zoom ·
J clipping · ⌘Z undo · ⌘/ all shortcuts.
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..5c94f166
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,45 @@
+{
+ "nodes": {
+ "craft-fonts": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1791295400,
+ "narHash": "sha256-e+6HpOYoAFTh6mNfGBBw2njnBzcE8W8+Jybv1+dKOCA=",
+ "owner": "storytold",
+ "repo": "craft-fonts",
+ "rev": "abb83316d96aa59c1cf64784289e378fe9fa5695",
+ "type": "github"
+ },
+ "original": {
+ "owner": "storytold",
+ "repo": "craft-fonts",
+ "rev": "abb83316d96aa59c1cf64784289e378fe9fa5695",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1791260994,
+ "narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "craft-fonts": "craft-fonts",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..918053ac
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,115 @@
+{
+ description = "LightCraft — photo library and non-destructive raw developer, in pure Rust";
+
+ inputs = {
+ # LightCraft needs edition 2024 and rustc ≥ 1.90, so the flake tracks unstable. Consumers on a
+ # release channel can retarget it: `inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";`
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+ # Optional build input of the repository: the Japanese fonts (BIZ UDPGothic, BIZ UDMincho,
+ # Shippori Mincho) that `crates/engine/build.rs` embeds when `CRAFT_FONTS_DIR` is set. Official
+ # releases always build with them; without them everything works but Japanese text has no
+ # glyphs. Pinned to the revision the release workflow uses — bump deliberately
+ # (`nix flake update craft-fonts`); craftrules standards/fonts.md.
+ craft-fonts = {
+ url = "github:storytold/craft-fonts/abb83316d96aa59c1cf64784289e378fe9fa5695";
+ flake = false;
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ craft-fonts,
+ }:
+ let
+ inherit (nixpkgs) lib;
+
+ # No x86_64-darwin: the pinned nixpkgs (26.11, "nixos-unstable") dropped support for it. On
+ # an Intel Mac, retarget nixpkgs at a release that still has it, or install the .dmg from the
+ # GitHub release.
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "aarch64-darwin"
+ ];
+
+ # The AppStream metadata carries a release date. The flake source's own commit date is
+ # reproducible, unlike "now"; a tarball or dirty tree without one falls back to the epoch.
+ buildDate =
+ let
+ d = self.lastModifiedDate or "";
+ in
+ if builtins.stringLength d >= 8 then
+ "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}"
+ else
+ "1970-01-01";
+
+ packageArgs = {
+ inherit craft-fonts buildDate;
+ };
+
+ forAllSystems = f: lib.genAttrs systems (system: f (import nixpkgs { inherit system; }) system);
+ in
+ {
+ packages = forAllSystems (
+ pkgs: _:
+ let
+ lightcraft = pkgs.callPackage ./nix/package.nix packageArgs;
+ in
+ {
+ inherit lightcraft;
+ default = lightcraft;
+ }
+ );
+
+ # `nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]` → `pkgs.lightcraft` everywhere.
+ overlays.default = final: _prev: {
+ lightcraft = final.callPackage ./nix/package.nix packageArgs;
+ };
+
+ devShells = forAllSystems (
+ pkgs: system:
+ let
+ lightcraft = self.packages.${system}.lightcraft;
+ in
+ {
+ default = pkgs.mkShell (
+ {
+ # The package's own native inputs, so `cargo build` in the shell needs no extra setup.
+ inputsFrom = [ lightcraft ];
+ packages = with pkgs; [
+ cargo
+ rustc
+ clippy
+ rustfmt
+ rust-analyzer
+ ];
+ # `cargo run` dlopens the Vulkan loader and libxkbcommon like the packaged binary.
+ LD_LIBRARY_PATH = lib.makeLibraryPath lightcraft.passthru.runtimeLibs;
+ RUST_BACKTRACE = "1";
+ }
+ // lib.optionalAttrs (craft-fonts != null) {
+ # Japanese glyphs in `cargo run` too; build.rs reads this as CRAFT_FONTS_DIR.
+ CRAFT_FONTS_DIR = toString craft-fonts;
+ }
+ );
+ }
+ );
+
+ checks = forAllSystems (
+ pkgs: system: {
+ lightcraft = self.packages.${system}.lightcraft;
+
+ # `nix fmt` formats the flake; this keeps it formatted.
+ formatting = pkgs.runCommand "lightcraft-nix-formatting" { nativeBuildInputs = [ pkgs.nixfmt ]; } ''
+ nixfmt --check ${./flake.nix} ${./nix}
+ touch $out
+ '';
+ }
+ );
+
+ formatter = forAllSystems (pkgs: _: pkgs.nixfmt);
+ };
+}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 00000000..20b895c7
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,206 @@
+# LightCraft — the desktop app (`lightcraft`) and the CLI/MCP server (`lightcraft-cli`), built from
+# this repository:
+#
+# nix build # → ./result/bin/lightcraft, ./result/bin/lightcraft-cli
+#
+# Kept in step with the release workflow (.github/workflows/release.yml) and packaging/env.sh:
+# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump — only
+# crates.io, no git sources;
+# * `CRAFT_FONTS_DIR` embeds the Japanese fonts from storytold/craft-fonts (the `craft-fonts`
+# flake input); without it everything builds and runs, but Japanese text has no glyphs;
+# * the desktop file, hicolor icons and AppStream metadata are the same files the .deb/.rpm ship
+# (packaging/linux/), so `apt` and NixOS users see one identical LightCraft;
+# * `doCheck` runs `cargo test --workspace`, what `cargo xtask ci` runs. `nix build` runs it too;
+# `pkgs.lightcraft.overrideAttrs { doCheck = false; }` skips it for a faster, build-only install.
+#
+# Everything in the product is pure Rust (no C/C++ dependencies), so this needs no build system
+# beyond cargo plus the windowing headers winit's build scripts look for.
+{
+ lib,
+ stdenv,
+ rustPlatform,
+ pkg-config,
+ makeWrapper,
+ # Windowing/GPU libraries: needed at build time by the crates that link them, at run time by the
+ # ones that open them with dlopen() (see `runtimeLibs`), and by the dev shell (flake.nix).
+ wayland,
+ wayland-scanner,
+ libxkbcommon,
+ libx11,
+ libxcb,
+ libxcursor,
+ libxi,
+ libxrandr,
+ vulkan-loader,
+ # Validates the desktop file and the AppStream metadata we install (as CI does).
+ desktop-file-utils,
+ appstream,
+ # Adds the driver search path (/run/opengl-driver/lib) to the binaries' RPATH so wgpu finds the
+ # installed Vulkan driver on NixOS. Optional: harmless on nixpkgs without it.
+ autoAddDriverRunpath ? null,
+ # storytold/craft-fonts checkout (flake input). `null` builds without Japanese glyphs.
+ craft-fonts ? null,
+ # Release date for the AppStream metadata (YYYY-MM-DD); the flake derives it from its own commit.
+ buildDate ? "1970-01-01",
+}:
+
+let
+ root = ../.;
+
+ # The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml.
+ version = (builtins.fromTOML (builtins.readFile (root + "/Cargo.toml"))).workspace.package.version;
+
+ isLinux = stdenv.hostPlatform.isLinux;
+
+ # AppStream id: also the desktop file name, and the icon name in share/icons/hicolor.
+ appId = "ai.storyteller.lightcraft";
+
+ # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at
+ # them — winit loads libxkbcommon/libxcb, wgpu the Vulkan loader (NixOS patches that loader to
+ # find the drivers in /run/opengl-driver). The wrapper below points LD_LIBRARY_PATH here.
+ runtimeLibs = lib.optionals isLinux [
+ libxkbcommon
+ wayland
+ libx11
+ libxcb
+ libxcursor
+ libxi
+ libxrandr
+ vulkan-loader
+ ];
+
+ # The two native binaries. The wasm app (apps/lightcraft-web) is built by `cargo xtask web`, not
+ # here; xtask is tooling.
+ binaries = [
+ "lightcraft"
+ "lightcraft-cli"
+ ];
+in
+rustPlatform.buildRustPackage {
+ pname = "lightcraft";
+ inherit version;
+
+ src = lib.cleanSourceWith {
+ src = lib.cleanSource root;
+ # The Nix files are not part of the build: editing them must not rebuild any Rust.
+ filter =
+ path: _type:
+ !(builtins.elem (baseNameOf path) [
+ "flake.nix"
+ "flake.lock"
+ "nix"
+ ]);
+ };
+
+ cargoLock.lockFile = root + "/Cargo.lock";
+
+ # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`).
+ cargoBuildFlags = [
+ "--locked"
+ "-p"
+ "lightcraft"
+ "-p"
+ "lightcraft-cli"
+ ];
+
+ # `cargo xtask ci` runs `cargo test --workspace`. Tests that need what the sandbox cannot have
+ # skip themselves with a note: corpus/ (gitignored, `cargo xtask corpus --download`) and the GPU
+ # equivalence tests (no adapter).
+ cargoTestFlags = [ "--workspace" ];
+
+ # One test thread at a time (RUST_TEST_THREADS=1). `lightcraft-catalog`'s `tests_lock` tests share
+ # a process-global resource: `flock` is inherited across `fork`, so a child process that another
+ # test forked keeps the parent's just-released library lock alive until its `execve` closes the
+ # (CLOEXEC) fd — long enough that the sibling test sees a spurious `LockError::InUse`, on ~30 % of
+ # runs on a many-core machine. Serializing the tests keeps `nix build` deterministic without
+ # touching the repository. The underlying window (any `fork` in a process holding the library
+ # lock — the app spawns `xdg-open`/`open`/`explorer` and `sysctl`) is a product-level question:
+ # `LibraryLock::acquire` could retry on `WouldBlock` for a few hundred ms before reporting InUse.
+ dontUseCargoParallelTests = true;
+
+ strictDeps = true;
+
+ nativeBuildInputs = [
+ pkg-config
+ ]
+ ++ lib.optionals isLinux [
+ makeWrapper
+ wayland-scanner
+ desktop-file-utils
+ appstream
+ ]
+ ++ lib.optionals (isLinux && autoAddDriverRunpath != null) [ autoAddDriverRunpath ];
+
+ buildInputs = lib.optionals isLinux [
+ wayland
+ libxkbcommon
+ libx11
+ libxcb
+ libxcursor
+ libxi
+ libxrandr
+ ];
+
+ env = lib.optionalAttrs (craft-fonts != null) {
+ CRAFT_FONTS_DIR = toString craft-fonts;
+ # A malformed font input must fail the build rather than silently ship without glyphs.
+ CRAFT_FONTS_REQUIRED = "1";
+ };
+
+ postInstall = ''
+ # Desktop integration: byte-for-byte the files the .deb/.rpm/.AppImage install.
+ install -Dm644 packaging/linux/${appId}.desktop $out/share/applications/${appId}.desktop
+ install -Dm644 packaging/linux/${appId}.mime.xml $out/share/mime/packages/${appId}.xml
+ cp -r assets/app-icon/hicolor $out/share/icons/
+ install -d $out/share/metainfo
+ substitute packaging/linux/${appId}.metainfo.xml.in \
+ $out/share/metainfo/${appId}.metainfo.xml \
+ --subst-var-by VERSION ${version} \
+ --subst-var-by DATE ${buildDate}
+
+ # Licences for everything embedded in the binaries: Inter, the app icon, each craft font.
+ doc=$out/share/doc/lightcraft
+ install -Dm644 -t "$doc" \
+ README.md LICENSE-MIT LICENSE-APACHE NOTICE \
+ assets/ATTRIBUTION.md assets/fonts/OFL-Inter.txt
+ ''
+ + lib.optionalString (craft-fonts != null) ''
+ for lic in ${toString craft-fonts}/fonts/*/OFL.txt; do
+ install -Dm644 "$lic" "$doc/OFL-$(basename "$(dirname "$lic")").txt"
+ done
+ ''
+ + lib.optionalString isLinux ''
+ desktop-file-validate $out/share/applications/${appId}.desktop
+ XDG_CACHE_HOME=$TMPDIR/.cache appstreamcli validate --no-net \
+ $out/share/metainfo/${appId}.metainfo.xml
+
+ # makeWrapper keeps the binaries working when the libs above are only dlopen'd.
+ for bin in ${lib.concatStringsSep " " (map (b: "$out/bin/" + b) binaries)}; do
+ wrapProgram "$bin" --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}"
+ done
+ '';
+
+ passthru = {
+ # Shared with the dev shell so `cargo run` inside `nix develop` finds the same dlopen'd libs.
+ inherit runtimeLibs;
+ };
+
+ meta = {
+ description = "Photo library and non-destructive raw developer";
+ longDescription = ''
+ LightCraft organises a photo library and develops raw files non-destructively: masks,
+ presets, colour grading, local adjustments and batch export, driven by the same command
+ layer as its UI. It ships with lightcraft-cli, a headless renderer, command runner and MCP
+ server for AI agents.
+ '';
+ homepage = "https://getartcraft.com/apps/lightcraft";
+ license = with lib.licenses; [
+ mit
+ asl20
+ ];
+ sourceProvenance = with lib.sourceTypes; [ fromSource ];
+ platforms = lib.platforms.linux ++ lib.platforms.darwin;
+ # `nix run` and the desktop entry both start the GUI.
+ mainProgram = "lightcraft";
+ };
+}