# The work is here now, in recipe bodies, where it used to be in scripts/ — # babashka scripts run through a scripts/bb that went looking for a babashka. # That indirection bought one thing worth having, a shared way to reach nix on # a host that keeps it in a container, and `nix` below is the whole of it. # # Every recipe that runs frq has the same shape: outside the dev shell, re-enter # it and come back to this same recipe; inside, hand jolt the deps overrides and # the library path the shell exported. The re-entry test is JOLT_NATIVE_LIB, # which only the shell sets — no flag to forget, and no second code path for # someone who runs `nix develop --command just cosmic run` by hand. set shell := ["bash", "-euo", "pipefail", "-c"] # Every recipe below is a `#!` script and passes its arguments on with "$@". # Without this that is empty in one — just interpolates into a shebang recipe # rather than handing it argv — and `just tui --headless` silently ran the # terminal instead. set positional-arguments # nix is not on every host this runs on: on the machine these recipes were # written for it lives in an Arch distrobox, at the same path — which is why # the container is entered rather than the tree copied into it. See CLAUDE.md. nix := `command -v nix >/dev/null 2>&1 && echo nix || echo "distrobox enter arch -- nix"` # --max-jobs 0 is what sends the work to the `builders` entry rather than # compiling it here. Left to the default, nix prefers the local machine, and a # cold jolt-native is egui, openh264 and quinn on a laptop — for a derivation a # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on # a machine with no builder configured. jobs := env("FRQ_MAX_JOBS", "0") default: @just --list # Re-read the COSMIC theme into the APK. # # libcosmic asks cosmic-config for the accent and the surfaces at run time, so # `just cosmic run` already follows COSMIC Settings as it changes. A phone has # no cosmic-config, so the APK carries them instead — read here, on the machine # that has them, and compiled in. That is the one real difference between the # two, and it is why the generated file is in git rather than gitignored: a # checkout on a machine with no COSMIC still builds. # # Run it after changing the theme in COSMIC Settings, then `just apk`. theme: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" python3 tools/cosmic2cljd.py flutter/src/frq/theme/cosmic.cljd # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build. # # Impure on purpose, and worth saying why rather than leaving it to be # discovered. Gradle resolves its own dependencies over the network and # installs build-tools and a platform into ANDROID_HOME as it goes, so it # cannot run in a sandbox and cannot write to the store. What nix gives here # is the toolchain — clojure, a JDK, Flutter, and an SDK composed by # androidenv — and the recipe copies that SDK somewhere writable # (flutter/.home) for Gradle to finish off. That copy and everything Gradle # leaves behind are gitignored. # # No ndkVersion in android/app/build.gradle.kts, for the same reason: the # Flutter template sets it, setting it makes Gradle fetch that exact NDK, and # there is no native code here to need one. # # just apk build the debug APK # just apk install build it and put it on a connected device # just apk run install and launch # just apk log logcat, filtered to this app apk action="build": #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}/flutter" # The flake's, not an --impure --expr against whatever nixos-unstable is # today: the licence config the SDK needs lives in `androidPkgsFor` now, # so this is an ordinary output at the rev flake.lock pins. sdk="$(nix build --no-link --print-out-paths \ "{{justfile_directory()}}#android-sdk")/libexec/android-sdk" # adb keeps the key the phone has already trusted under the real HOME, and # HOME moves below so Gradle can write into the SDK copy. Told where to # look, adb keeps its identity; left to find $HOME/.android it generates a # new one, the device stops recognising this machine, and the deploy ends # in "no devices/emulators found" while `adb devices` in any other shell # lists it perfectly well. export ANDROID_USER_HOME="${ANDROID_USER_HOME:-$HOME/.android}" export HOME="$PWD/.home" export ANDROID_HOME="$HOME/android-sdk" export ANDROID_SDK_ROOT="$ANDROID_HOME" mkdir -p "$HOME" # Gradle writes into ANDROID_HOME, so it is a copy rather than the store # path. Made once and kept: re-copying would throw away the build-tools # and platform Gradle installed into it on the last run. if [ ! -d "$ANDROID_HOME" ]; then cp -r "$sdk" "$ANDROID_HOME" chmod -R u+w "$ANDROID_HOME" fi # The compile's three caches, same flake-output-and-copy shape as the SDK # and for the same reason: tools.deps and pub both write into theirs, and # the store is read-only. What this buys is the "Resolving dependencies… # Downloading packages…" that used to open every run. deps="$(nix build --no-link --print-out-paths \ "{{justfile_directory()}}#cljd-deps")" # Neither of these follows HOME. Both are read off the JVM's user.home, # which comes from /etc/passwd rather than the environment — so moving # HOME below is not enough to move them, and left alone they would be the # real ~/.m2 and ~/.gitlibs, shared with every other project on the box. export GITLIBS="$HOME/gitlibs" m2="$HOME/m2" export PUB_CACHE="$HOME/.pub-cache" # Seeded once each and then left alone, exactly as ANDROID_HOME is: after # the first compile these hold whatever the working tree has asked for # since, and re-copying would throw that away. seed() { [ -e "$2" ] && return 0 mkdir -p "$(dirname "$2")" cp -r "$deps/$1" "$2" chmod -R u+w "$2" } seed m2 "$m2" seed gitlibs "$GITLIBS" seed pub-cache "$PUB_CACHE" seed clojuredart/cache "$PWD/.clojuredart/cache" # Also the flake's. `nix shell nixpkgs#...` read the registry, which is a # different and unlocked nixpkgs — the Flutter that built the APK could # move under it without flake.lock changing a line. flutter="nix develop {{justfile_directory()}}#flutter --command" # cljd-deps ships the analyzer project unresolved — a fixed-output # derivation may not name the store, and a resolved pub project is # nothing but store paths. So it is resolved here instead, offline, # against the cache that derivation did fetch. ClojureDart reaches for the # network only when bin/analyzer.dart is missing, and after this it is not. for helper in .clojuredart/cache/*/cljd_helper; do [ -d "$helper" ] || continue [ -e "$helper/.dart_tool/package_config.json" ] && continue ( cd "$helper" && $flutter flutter pub get --offline ) done $flutter clojure -Sdeps "{:mvn/local-repo \"$m2\"}" -M:cljd compile # Rewritten every run: it carries absolute store paths, and the flutter # one moves whenever nixpkgs does. $flutter flutter config --android-sdk "$ANDROID_HOME" >/dev/null apk=build/app/outputs/flutter-apk/app-debug.apk adb="${ADB:-$ANDROID_HOME/platform-tools/adb}" case "{{action}}" in build) $flutter flutter build apk --debug ;; install) $flutter flutter build apk --debug && "$adb" install -r "$apk" ;; run) $flutter flutter build apk --debug && "$adb" install -r "$apk" \ && "$adb" shell monkey -p uk.nandi.frq -c android.intent.category.LAUNCHER 1 ;; log) "$adb" logcat -s flutter ;; *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;; esac # Two halves, and the split is the point. The frq source is the files on disk, # uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-cosmic # and the native objects — is the flake's, built rather than fetched. # # Deliberately not `nix run .#frq`. That builds the flake's own copy of the # source, which is the tree as git has it — so an edit that has not been # committed, or committed on a branch the command was not pointed at, runs as # whatever was there before, silently. A run meant to answer "does my change # work" has to be the files on disk. # # libcosmic paints through wgpu, so this needs nixGL off NixOS for the same # reason the window always did: the real driver is the host's. # # There is no jvui and no vidya here any more — both were experiments. The # window is libcosmic and the terminal is libjolttui, and those are the two. # # The app: this tree's source on the flake's everything-else, in the dev shell. # # Named for the backend rather than for the verb, the way `flutter-desktop` # is: two desktop GUIs, neither of them the default one. # # just cosmic run [args...] open the window cosmic action="run" *args: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" if [ "{{action}}" != "run" ]; then echo "usage: just cosmic run [args...]" >&2 exit 1 fi shift if [ -z "${JOLT_NATIVE_LIB:-}" ]; then exec {{nix}} develop . --max-jobs {{jobs}} --command just cosmic run "$@" fi deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}" deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}" export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" runner=() [ -e /run/current-system ] || runner=("$NIXGL") exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@" # `cosmic` with the other backend under it. Only libjolttui: `frq.app` names no # backend at all any more, and `frq.tui` requires glimmer-tui so the one # installed is the terminal. # # No nixGL here, unlike `cosmic`: a terminal wants nothing from the host's GL # driver, which is the reason this output exists on machines that have none. # # What may appear in common/, checked — the half of the tree both backends # compile. Needs nothing built: it reads the source, so it is the one check # that runs anywhere, and CI runs exactly this. check-common: #!/usr/bin/env bash python3 tools/check-common.py common # The same screens in a terminal. `just tui --headless` prints one screenshot. tui *args: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" if [ -z "${JOLT_NATIVE_LIB:-}" ]; then exec {{nix}} develop . --max-jobs {{jobs}} --command just tui "$@" fi deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}" deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}" export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" exec jolt -Sdeps "$deps" -m frq.tui "$@" # The same classpath as `tui`, with an nREPL on it instead of a `-main`: a # session that can require `frq.tui` and then redefine a component while it is # on screen, which is a second and not the minute a rebuild costs. # # just nrepl then, from an editor or a client on 7888: # (require (quote frq.tui)) both backends, terminal installed last # (frq.tui/-main "--headless" "--demo") # (glimmer.core/reload!) re-mount after redefining a component # # `just repl nrepl-server` is the window's half of this — the same thing minus # glimmer-tui. Port is nrepl-server's own positional: `just nrepl 7889`. nrepl *args: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" if [ -z "${JOLT_NATIVE_LIB:-}" ]; then exec {{nix}} develop . --max-jobs {{jobs}} --command just nrepl "$@" fi deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}" deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}" export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" exec jolt -Sdeps "$deps" nrepl-server "$@" # `jolt` in the repo root does not work on its own: deps.edn carries # :jolt/native, so every invocation here loads libvidya and libjoltmoq before it # reads a line, and dies naming the library if the loader cannot find them. So # this is `cosmic` without the app — and `cosmic` is this with a window's # worth of # extra care about the GL driver. # # A jolt with the native libraries under it: a REPL, or `just repl nrepl-server`. repl *args: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" if [ -z "${JOLT_NATIVE_LIB:-}" ]; then exec {{nix}} develop . --max-jobs {{jobs}} --command just repl "$@" fi deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}" deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}" export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" exec jolt -Sdeps "$deps" "$@" # Regenerate src/frq/moq/raw.clj from the libmoq_ffi we actually load. # # UniFFI embeds its interface metadata in the object, so `uniffi-bindgen # --library` reads the truth out of the .so rather than a header shipped # beside it. Those differ at one version number: the published Linux and # Android objects are built without moq-ffi's `audio` and `video` features # (206 functions, no codecs), while the shipped C header describes the Apple # build (230). The object is the only source this recipe will accept. # # The bindgen must match the uniffi that built the object — 0.32 for moq-ffi # 0.3.17 — and it is built once into the scratch dir rather than pinned into # the flake: nothing in a normal build needs it, and a regeneration is a thing # done when the moq-ffi pin moves, by hand, on purpose. # # just gen-moq # the loaded library # just gen-moq path/to/libmoq_ffi.so # # Regenerate the libmoq_ffi bindings from the object's own embedded metadata. gen-moq lib="": #!/usr/bin/env bash set -euo pipefail lib="${1:-${JOLT_NATIVE_LIB:-}/libmoq_ffi.so}" [ -f "$lib" ] || { echo "no libmoq_ffi.so at $lib — pass one: just gen-moq " >&2; exit 1; } work="${TMPDIR:-/tmp}/frq-gen-moq" mkdir -p "$work/ubg/src" cat > "$work/ubg/Cargo.toml" <<'TOML' [package] name = "ubg" version = "0.1.0" edition = "2021" [[bin]] name = "uniffi-bindgen" path = "src/main.rs" [dependencies] uniffi = { version = "0.32", features = ["cli"] } TOML echo 'fn main() { uniffi::uniffi_bindgen_main() }' > "$work/ubg/src/main.rs" ( cd "$work/ubg" && cargo build --release -q ) ( cd "$work/ubg" && ./target/release/uniffi-bindgen generate \ --library "$lib" --language python --out-dir "$work/py" --no-format ) python3 tools/py2jolt.py "$work"/py/*.py > "$work/body.clj" { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj" mv "$work/raw.clj" src/frq/moq/raw.clj echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)" # The other desktop GUI: the same screens, painted by Flutter instead of # libcosmic. # # `just cosmic run` and this one are two frontends over one tree, and the # split is the same one the APK already draws. Everything under `common/` — the # screens, the cells, `frq.io` — is shared; what differs is who paints it and # who answers the host. So this recipe is `just apk` with the Android half # taken out: the same `clojure -M:cljd compile` over the same flutter/src, # then Flutter's Linux target rather than its Android one. CMake and Ninja # instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK. # # Still impure, for one of the two reasons `apk` is: pub.dev resolution and # Flutter's own engine artifacts are network. What it does NOT need is the # writable-ANDROID_HOME dance — nothing here writes into the store — so there # is no `flutter/.home` on this path. # # nixGL for the reason `cosmic` needs it and `tui` does not: Flutter paints # through GL, and off NixOS the driver is the host's. # # just flutter-desktop build the debug bundle # just flutter-desktop run build it and open the window flutter-desktop action="build": #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ --command just flutter-desktop "$@" fi cd flutter # The same three caches `just apk` seeds, in the same place and out of the # same flake output — one compiler, one set of dependencies, and no reason # for the two frontends to keep a copy each. `.home/` is `apk`'s directory # by name and this is the only thing put there from here, which is the # point: whichever recipe runs first pays for the copy and the other finds # it warm. # # No `nix build` here, unlike `apk`: this recipe is already inside the # shell that names FRQ_CLJD_DEPS by the time it gets this far, and `apk` # needs the path before it enters anything. # # m2 and gitlibs are set here for the reason they are set there — the JVM # reads user.home out of /etc/passwd, so neither follows HOME and left # alone they are the real ~/.m2 and ~/.gitlibs. export PUB_CACHE="$PWD/.home/.pub-cache" export GITLIBS="$PWD/.home/gitlibs" m2="$PWD/.home/m2" seed() { [ -e "$2" ] && return 0 mkdir -p "$(dirname "$2")" cp -r "$FRQ_CLJD_DEPS/$1" "$2" chmod -R u+w "$2" } seed m2 "$m2" seed gitlibs "$GITLIBS" seed pub-cache "$PUB_CACHE" seed clojuredart/cache "$PWD/.clojuredart/cache" # Resolved here rather than in cljd-deps, which was not allowed to name # the store — see the same loop in `apk`. for helper in .clojuredart/cache/*/cljd_helper; do [ -d "$helper" ] || continue [ -e "$helper/.dart_tool/package_config.json" ] && continue ( cd "$helper" && flutter pub get --offline ) done clojure -Sdeps "{:mvn/local-repo \"$m2\"}" -M:cljd compile flutter build linux --debug # x64/arm64 is Flutter's own name for the host arch, not uname's. case "$(uname -m)" in x86_64) arch=x64 ;; aarch64) arch=arm64 ;; *) echo "unknown arch $(uname -m)" >&2; exit 1 ;; esac bundle="build/linux/$arch/debug/bundle" case "{{action}}" in build) echo "built $PWD/$bundle/frq" ;; run) runner=() [ -e /run/current-system ] || runner=("$NIXGL") exec "${runner[@]}" "$bundle/frq" ;; *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; esac # The third frontend: the same screens again, compiled to JavaScript. # # `flutter-desktop` with the Linux half taken out. One `clojure -M:cljd # compile` over the same flutter/src and common/, then Flutter's web target # instead of its Linux one — dart2js instead of CMake and Ninja, and a # directory of static files instead of a bundle with an executable in it. # # And the one target with no nix in it. The other two need the host: a JDK # and the Android SDK for `apk`, GTK and a C++ toolchain and nixGL for # `flutter-desktop`. This one needs a Dart, a JVM and a browser, and the # browser is not ours — so `tools/toolchain.sh` fetches the first two as # pinned tarballs into `.toolchain/` and there is nothing left for a devShell # to supply. That is what lets the container in `.modal/flutter-web/` drop # its image build too: same script, same three pins, no store to populate. # # Impure for the reason the other two are: pub.dev resolution and Flutter's # engine artifacts are network, and now the toolchain is as well — pinned by # sha256, which is the reproducibility that was worth having out of the store. # # The entry point is `frq.main-web`, not `frq.main`: path_provider has no web # implementation, so the `getApplicationSupportDirectory` that `frq.main` # awaits throws MissingPluginException before any widget is built. The web # entry installs `frq.io.web` — localStorage behind the same seam — and awaits # nothing. `frq.net.dart` is still the socket half, so connecting will want a # WebSocket before this does more than paint. # # One build and no `--debug` variant, because there is nothing to gain from # one: dart2js at -O1 measured 52.5s against the release build's 49.8s on the # same source change here, so a second, larger bundle would buy noise. See # `tools/build-web.sh`, which writes the numbers down. # # just flutter-web build build/web # just flutter-web serve build it and serve it on $PORT (8080) # just flutter-web serve 3000 ...on another port flutter-web action="build" port="8080": #!/usr/bin/env bash set -euo pipefail # A wrapper and nothing else. The build is a shell script because the # container runs it too, and a container that had to install `just` to # start would be one dependency away from the point. exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}} # The containers in `.modal/`, run on Modal rather than here. This machine # evaluates and Modal builds — see CLAUDE.md, which says so rather more # firmly — and these two recipes are the whole interface to that. # # Named for where the work happens, the way `cosmic` and `flutter-desktop` # are named for what paints: there is no re-entry test here because nothing # re-enters, and no `nix` variable because nix runs out there. # # just modal frq build `.#appimage` on Modal # just modal flutter-dev the incremental Flutter loop modal container="frq" *args: #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" shift exec modal run ".modal/{{container}}/container.py" "$@" # The Modal-built web bundle, served from this machine on localhost. # # Not a local build: `modal volume get` pulls what `just modal flutter-web` # already compiled out of the devshell volume, so this needs no Flutter, no # Dart and no nix — only python, which the flake shell has and so does the # machine. # # It exists for one reason, and the reason is the auth broker rather than # convenience. freeq's broker finishes an OAuth login by redirecting the # browser to `return_to`, and it will only redirect to an origin on its # allowlist: its own https hosts, and `http://localhost` or `http://127.0.0.1` # on ANY port. A build served from anywhere else — the Modal URL included — # gets `400 Invalid return_to URL` and can never complete a Bluesky sign-in, # no matter what the client does. localhost is the one allowlisted origin we # can serve from, so this is how Bluesky sign-in is tested. # # Guest and app-password sign-in need none of this; they work on the deployed # URL, because neither goes near the broker. # # just web-local fetch and serve on :8080 # just web-local 3000 another port; any port is allowlisted web-local port="8080": #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" out="{{justfile_directory()}}/.web-local" mkdir -p "$out" echo "fetching the Modal-built bundle…" # --force: this is a mirror of the volume, and a stale file left behind # would be served in preference to the one just built. modal volume get --force devshell frq-flutter-web/flutter/build/web "$out" echo echo " http://localhost:{{port}}" echo echo "Bluesky sign-in works here and not on the Modal URL: the broker" echo "allowlists localhost on any port. Put wss://irc.freeq.at/irc in the" echo "Server field — a browser has no TCP." exec python3 -m http.server {{port}} --bind 127.0.0.1 --directory "$out/web" # A sandbox left running with the container's own image, volumes and # environment, and the command to get into it. `modal shell --image` cannot # be pointed at a published Modal image like arch-nix, so attaching to a # running sandbox is the only way to get a shell that is the container. # # It blocks: an ephemeral app stops when its entrypoint returns and takes the # sandbox with it. Attach from a second terminal, and Ctrl-C here when done — # the sandbox bills until you do. # # just modal-shell flutter-dev, the usual one # just modal-shell frq the appimage container modal-shell container="flutter-dev": #!/usr/bin/env bash set -euo pipefail cd "{{justfile_directory()}}" exec modal run ".modal/{{container}}/container.py" --shell