nandi/frqpublic Fork 0
claude/add-remote-repository-47d12e
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Three tarballs where a devShell was 5ce66d5 · on claude/add-remote-repository-47d12e · nandi · 2h ago
justfile · 536 lines · 24.2 KBMakefile Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# 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 <path>" >&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