nandi/frqpublic Fork 0
9db383c
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.

A Mesa nobody needed, and the AppImage that carried it

`.modal/frq` built `.#appimage`: an Arch-with-nix image, a nix-cache volume,
an hour's timeout sized for libjoltcosmic's dependency tree, and a substituter
test to decide whether writing the cache back cost less than the build it
avoided. It is `debian:13-slim` now, and it compiles one .c file.

What changed is not the build getting cleverer. It is that every piece it was
building from source is now published in a form a machine without nix can
use: jolt as one statically-linked binary, the backends as jolt-native's new
`portable` tarball — RUNPATH $ORIGIN, NEEDED closure beside them — and
libmoq_ffi off an upstream release, which it always was.

That leaves the question of what the AppImage was FOR, and the answer is one
library. nix-appimage squashed the closure into a file so a host without nix
could run it, and the heaviest thing in that closure was a Mesa, carried so
nixGL had something to put the host's driver in front of. Carry no Mesa and
there is no nixGL and nothing to squash: the GL driver is the host's, like
every other program on the machine. glibc too. What comes out is a directory
that runs where it is unpacked.

`tools/desktop-toolchain.sh` holds the pins and `tools/build-desktop.sh` does
the assembly, the way toolchain.sh and build-web.sh do for the web target —
and `just desktop` runs the same script the container does, so what a laptop
assembles and what CI ships are one thing.

One pin is a placeholder. JOLT_NATIVE_SHA waits on jolt-native's pipeline
publishing a portable tarball for the revision named beside it; the script
refuses to fetch until it is real rather than falling back to an unpinned
download, because that registry serves the newest upload under a given name.
It prints the command that fills it in.

The source revision for glimmer-cosmic and the tarball's revision are one
variable, deliberately: the two talk over a retained-tree ABI that is not
versioned, and the flake's note on that input records what the drift cost last
time — a hover card sent to a backend with no handler for one, and a pill that
said nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T17:11:32-07:00 Browse files
9db383c parent: 41bcb21
modified .modal/frq/README.md +72 -10
@@ -1,16 +1,78 @@
11 # `frq`
22
3- scripts/deploy frq
4- modal run containers/frq/container.py
3+ modal run .modal/frq/container.py
4+ just modal frq
55
66 Defined by `container.toml`; see `../spec.md` for the keys.
7-Built on the published `arch-nix` image.
7+Built on `debian:13-slim`.
88
9-Runs as a Sandbox on a real VM (kernel 6.x, not gVisor). The command
10-is the sandbox's own process, so it dies when the command exits --
11-no idle window and nothing to tear down. Note the VM restrictions:
12-no GPU, and memory is exactly what `[resources] memory` asks for.
9+No nix, and — as with `flutter-web` — that is the point of this
10+container rather than an incidental fact about it. What comes out is
11+`build/frq-desktop-x86_64-linux.tar.gz`: a directory with a jolt
12+binary, the native objects, this tree's source, glimmer and
13+glimmer-cosmic, and a launcher. Unpack it anywhere and run
14+`bin/frq`.
1315
14-No nix at run time: nothing is substituted at build time and nothing
15-is evaluated at start. Add a `flake.nix` and set `[nix] flake`/`shim`
16-together if you want a devShell, knowing what it costs.
16+## What replaced the AppImage
17+
18+It used to be `nix build .#appimage` on an Arch-with-nix image,
19+against a `nix-cache` volume, with an hour's timeout sized for
20+libjoltcosmic's dependency tree and a substituter test to decide
21+whether writing the cache back was worth more than the build it
22+avoided.
23+
24+None of that was nix doing a bad job. It was nix building from
25+source what is now published in a form a machine without nix can
26+use:
27+
28+| piece | where it comes from now |
29+| --- | --- |
30+| `jolt` | a release binary, chez linked in statically — stock `/lib64` interpreter, NEEDED `libc` and `libm` |
31+| `libjoltcosmic`, `libjolttui`, `libvidya`, `libjoltmoq` | jolt-native's `x86_64-linux-portable.tar.gz` — RUNPATH `$ORIGIN`, NEEDED closure alongside |
32+| `libmoq_ffi` | an upstream release object |
33+| `glimmer`, `glimmer-cosmic` | source, cloned at a pinned rev |
34+| `libfrqh264.so` | one `.c` file, compiled here |
35+
36+What was left after that was a Mesa, and a nixGL to put the host's
37+driver in front of it. The AppImage existed to carry the closure
38+that Mesa was part of. Not carrying a Mesa means not needing a
39+nixGL, which means not needing the bundle — the GL driver is the
40+host's, the way it is for every other program on the machine.
41+
42+`tools/desktop-toolchain.sh` holds the pins and
43+`tools/build-desktop.sh` does the assembly; `just desktop` runs the
44+same script on a laptop. The toolchain lands on the `devshell`
45+volume and a second run finds it there.
46+
47+## The pin that has to be filled in
48+
49+`JOLT_NATIVE_SHA` in `tools/desktop-toolchain.sh` is a placeholder
50+until jolt-native's pipeline has published a portable tarball for
51+the revision named beside it. The script refuses to fetch until it
52+is real rather than falling back to an unpinned download — the
53+package registry serves the newest upload under a given name, which
54+is exactly the moving target a pin is for. It prints the two
55+commands that fix it.
56+
57+## What is not in the bundle
58+
59+The GL driver and glibc, on purpose — both are the host's, and a
60+newer loader can load an older program's libraries rather than the
61+reverse.
62+
63+The ALSA PipeWire plugin, also the host's. The flake named a store
64+path for it; the launcher looks in the three places a distro puts
65+it. Without it `default` resolves only to raw hardware devices,
66+which PipeWire is already holding.
67+
68+`libjolttui` rides along in the tarball because it is in the
69+portable one, but nothing in this bundle starts it — `.#tui` is
70+still a nix output and the terminal backend needs no bundle to be
71+useful.
72+
73+## Runs as a Sandbox
74+
75+On a real VM (kernel 6.x, not gVisor), the command being the
76+sandbox's own process, so it dies when the command exits. It builds
77+only: there is no GL and no display here, and nothing tries to open
78+the window.
@@ -1,16 +1,78 @@
1 # `frq`1 # `frq`
2 2
3- scripts/deploy frq3+ modal run .modal/frq/container.py
4- modal run containers/frq/container.py4+ just modal frq
5 5
6 Defined by `container.toml`; see `../spec.md` for the keys.6 Defined by `container.toml`; see `../spec.md` for the keys.
7-Built on the published `arch-nix` image.7+Built on `debian:13-slim`.
8 8
9-Runs as a Sandbox on a real VM (kernel 6.x, not gVisor). The command9+No nix, and — as with `flutter-web` — that is the point of this
10-is the sandbox's own process, so it dies when the command exits --10+container rather than an incidental fact about it. What comes out is
11-no idle window and nothing to tear down. Note the VM restrictions:11+`build/frq-desktop-x86_64-linux.tar.gz`: a directory with a jolt
12-no GPU, and memory is exactly what `[resources] memory` asks for.12+binary, the native objects, this tree's source, glimmer and
13+glimmer-cosmic, and a launcher. Unpack it anywhere and run
14+`bin/frq`.
13 15
14-No nix at run time: nothing is substituted at build time and nothing16+## What replaced the AppImage
15-is evaluated at start. Add a `flake.nix` and set `[nix] flake`/`shim`17+
16-together if you want a devShell, knowing what it costs.18+It used to be `nix build .#appimage` on an Arch-with-nix image,
19+against a `nix-cache` volume, with an hour's timeout sized for
20+libjoltcosmic's dependency tree and a substituter test to decide
21+whether writing the cache back was worth more than the build it
22+avoided.
23+
24+None of that was nix doing a bad job. It was nix building from
25+source what is now published in a form a machine without nix can
26+use:
27+
28+| piece | where it comes from now |
29+| --- | --- |
30+| `jolt` | a release binary, chez linked in statically — stock `/lib64` interpreter, NEEDED `libc` and `libm` |
31+| `libjoltcosmic`, `libjolttui`, `libvidya`, `libjoltmoq` | jolt-native's `x86_64-linux-portable.tar.gz` — RUNPATH `$ORIGIN`, NEEDED closure alongside |
32+| `libmoq_ffi` | an upstream release object |
33+| `glimmer`, `glimmer-cosmic` | source, cloned at a pinned rev |
34+| `libfrqh264.so` | one `.c` file, compiled here |
35+
36+What was left after that was a Mesa, and a nixGL to put the host's
37+driver in front of it. The AppImage existed to carry the closure
38+that Mesa was part of. Not carrying a Mesa means not needing a
39+nixGL, which means not needing the bundle — the GL driver is the
40+host's, the way it is for every other program on the machine.
41+
42+`tools/desktop-toolchain.sh` holds the pins and
43+`tools/build-desktop.sh` does the assembly; `just desktop` runs the
44+same script on a laptop. The toolchain lands on the `devshell`
45+volume and a second run finds it there.
46+
47+## The pin that has to be filled in
48+
49+`JOLT_NATIVE_SHA` in `tools/desktop-toolchain.sh` is a placeholder
50+until jolt-native's pipeline has published a portable tarball for
51+the revision named beside it. The script refuses to fetch until it
52+is real rather than falling back to an unpinned download — the
53+package registry serves the newest upload under a given name, which
54+is exactly the moving target a pin is for. It prints the two
55+commands that fix it.
56+
57+## What is not in the bundle
58+
59+The GL driver and glibc, on purpose — both are the host's, and a
60+newer loader can load an older program's libraries rather than the
61+reverse.
62+
63+The ALSA PipeWire plugin, also the host's. The flake named a store
64+path for it; the launcher looks in the three places a distro puts
65+it. Without it `default` resolves only to raw hardware devices,
66+which PipeWire is already holding.
67+
68+`libjolttui` rides along in the tarball because it is in the
69+portable one, but nothing in this bundle starts it — `.#tui` is
70+still a nix output and the terminal backend needs no bundle to be
71+useful.
72+
73+## Runs as a Sandbox
74+
75+On a real VM (kernel 6.x, not gVisor), the command being the
76+sandbox's own process, so it dies when the command exits. It builds
77+only: there is no GL and no display here, and nothing tries to open
78+the window.
modified .modal/frq/container.toml +78 -91
@@ -1,9 +1,22 @@
11 [container]
22 name = "frq"
3-description = "nix build .#appimage -- the cosmic GUI, in one file"
4-base = "arch-nix"
5-# A Sandbox, not a Function: runs on a real VM, and the command is
6-# the sandbox's own process so it dies when the command does.
3+description = "the cosmic desktop bundle, from a pinned toolchain"
4+# `debian:13-slim` and not `arch-nix`: there is no nix in this container any
5+# more, and the AppImage it used to build is gone with it. What this assembles
6+# is a directory — a jolt binary, the native objects, the source and a
7+# launcher — and what it owes that is curl, git, tar, a C compiler and
8+# openh264's headers.
9+#
10+# The reason this could stop being a nix build is not that nix was doing it
11+# badly. It is that everything it was building from source is now published as
12+# something a machine without nix can use: jolt as one statically-linked
13+# binary, the backends as jolt-native's `portable` tarball (RUNPATH $ORIGIN,
14+# NEEDED closure alongside), libmoq_ffi as an upstream release object. What
15+# was left was a Mesa and a nixGL to put the host driver in front of it —
16+# and not carrying a Mesa is what means not needing a nixGL.
17+registry = "debian:13-slim"
18+# A Sandbox, not a Function: it runs on a real VM and the command is the
19+# sandbox's own process, so it dies when the command does.
720 runtime = "sandbox"
821
922 [build]
@@ -12,108 +25,82 @@ runtime = "sandbox"
1225 # the point of copying rather than fetching.
1326 context = "../.."
1427 include = ["."]
15-# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
16-# every start and wanted by nothing out there. Flutter builds into a volume of
17-# its own, and the jolt and clojure caches are this machine's.
28+# One apt line, where there used to be a nix store to populate. gcc and
29+# pkg-config for `c/frq_h264.c`, which is the only thing here that compiles;
30+# libopenh264-dev for its header and link; libopus and libasound because the
31+# bundle carries them out to the machine that runs it. patchelf to set
32+# $ORIGIN on the objects that arrive without it. git for the two pinned source
33+# checkouts, ca-certificates so curl can verify what it fetches.
34+setup = [
35+ "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git gcc libc6-dev pkg-config libopenh264-dev libopus-dev libasound2-dev patchelf rsync tar xz-utils && rm -rf /var/lib/apt/lists/*",
36+]
37+# The build state a local checkout carries, wanted by nothing out here.
1838 ignore = [
1939 "flutter/build", "flutter/.home", "flutter/.dart_tool",
20- "flutter/.clojuredart", "flutter/.cpcache",
40+ "flutter/.clojuredart", "flutter/.cpcache", "flutter/lib/cljd-out",
41+ # The toolchains, which live on the volume out here.
42+ ".toolchain", ".toolchain-desktop",
2143 ".jolt", ".cpcache", "result", "build", ".git",
44+ # An editor's linter rewrites this while the upload is reading it, and
45+ # Modal fails the whole run with "was modified during build process".
46+ ".clj-kondo",
2247 ]
2348
24-# The Modal Volume that makes a second build cheap. Mounted at run time, which
25-# is when the build happens here -- nothing is written to it while the image is
26-# built, because a volume mount is not part of the resulting image.
49+# `devshell` and not `nix-cache`, which went with nix — the same volume every
50+# other container's working state lives on, under a directory named for this
51+# one. The toolchain is what makes a second run cheap now: ~30MB of jolt and
52+# objects fetched once and found there afterwards.
2753 [volumes]
28-nix-cache = "/nix-cache"
54+devshell = "/devshell"
2955
3056 [resources]
31-cpu = 8
32-memory = 16384
33-# libjoltcosmic's tree is the long pole even when most of it substitutes.
34-timeout = 3600
57+# Four, not eight: nothing here compiles except one .c file. What this job
58+# spends its time on is fetching and copying.
59+cpu = 4
60+memory = 8192
61+# Minutes, not an hour. The old timeout was sized for libjoltcosmic's
62+# dependency tree, which is now somebody else's pipeline's problem.
63+timeout = 900
3564
3665 [run]
3766 workdir = "/app"
38-# The build, not the window: `#appimage` bundles `#frq` -- the cosmic GUI,
39-# whose frqScript runs `-m frq.cosmic` -- and nothing here tries to open it.
40-# There is no GL and no display on a build box, which is why this only builds.
41-#
42-# `#appimage` rather than `#frq` because of how the result gets home. A store
43-# path is only useful to a machine that can take its whole closure, which
44-# means walking ~200 narinfos out of the volume one call at a time and then
45-# importing them. The AppImage is that same closure squashed into one file:
46-# one `modal volume get`, no store import, and it runs off NixOS because the
47-# bundle carries the Mesa that nixGL puts the host driver in front of.
48-#
49-# `path:/app` and not `.`: a checkout copied in here brings its `.git` with
50-# it, and in a worktree that is a *file* naming a gitdir back on the host.
51-# Nix believes it, tries to open a repository that is not there, and fails
52-# before it evaluates anything. `path:` says plain directory and means it.
53-#
54-# The cache is read as a substituter and written with `nix copy --all`, but
55-# only when this run actually produced something the cache has not got. That
56-# test is the difference between a nine-minute run and a two-minute one: on a
57-# full hit `--all` still interrogates every one of the cache's thousands of
58-# paths to discover it has nothing to write, and that interrogation cost more
59-# than the build it exists to avoid. Asking whether the result's own narinfo
60-# is already there answers the same question in one stat.
61-#
62-# `--max-jobs auto` on the command line and not only in nix.conf: the base
63-# image carries that setting now, but only from the next `modal run
64-# arch_nix.py` onwards, and the flag costs nothing once it is redundant.
65-# Nix's default is 1 -- the whole graph end to end, one derivation at a time.
66-#
67-# `set -e` earns its place: the last command here is a `ls`, so without it a
68-# failed `nix build` would still leave the sandbox exiting 0 and the run would
69-# report success. The substituter test is an `if` rather than `&&` for the same
70-# reason -- under `set -e` a false `&&` would abort the whole script on the
71-# first run, when there is legitimately nothing in the cache yet.
67+# Source in, toolchain out of the volume, assemble, tar. The old command was a
68+# `nix build` of a closure with ~200 store paths in it, a substituter test to
69+# decide whether writing the cache back was worth it, and a copy of the
70+# AppImage into the volume. This is four steps and none of them evaluates
71+# anything.
7272 command = """
7373 set -e
74-mkdir -p /nix-cache/artifacts
75-subs=""
76-if [ -f /nix-cache/nix-cache-info ]; then
77- subs="--extra-substituters file:///nix-cache"
78- echo "cache: reading from /nix-cache"
79-else
80- echo "cache: empty, this run fills it"
81-fi
82-# One setting per invocation -- `nix config show a b` is an argument error,
83-# and under `set -e` that kills the run before it builds anything.
84-nix config show max-jobs
85-nix config show cores
74+BUILD_DIR=/devshell/frq-desktop
8675
87-out=$(nix build path:/app#appimage --accept-flake-config $subs \
88- --max-jobs auto --cores 0 --print-out-paths --print-build-logs)
89-echo "built: $out"
76+# Beside the working tree and NOT inside it: the rsync below runs with
77+# --delete, so anything under $BUILD_DIR that is not in /app is removed on
78+# every run. A toolchain kept in there would be deleted moments before it was
79+# consulted.
80+export FRQ_DESKTOP_TOOLCHAIN=/devshell/frq-desktop.toolchain
81+mkdir -p "$BUILD_DIR" "$FRQ_DESKTOP_TOOLCHAIN" /devshell/artifacts
9082
91-cp -L "$out" /nix-cache/artifacts/frq.AppImage
92-chmod +x /nix-cache/artifacts/frq.AppImage
93-ls -la /nix-cache/artifacts/frq.AppImage
83+echo "sync: /app -> $BUILD_DIR"
84+rsync -a --checksum --delete \
85+ --exclude '.toolchain/' \
86+ --exclude '.toolchain-desktop/' \
87+ --exclude '.git' \
88+ /app/ "$BUILD_DIR/"
9489
95-hash=$(basename "$out" | cut -d- -f1)
96-if [ -f "/nix-cache/$hash.narinfo" ]; then
97- echo "cache: $hash already held, nothing to write"
98-else
99- echo "cache: writing the store back"
100- nix copy --no-check-sigs --all --to file:///nix-cache
101-fi
102-"""
103-env = { }
90+cd "$BUILD_DIR"
91+[ -d "$FRQ_DESKTOP_TOOLCHAIN/jolt" ] && echo " carried over: the toolchain"
10492
105-[nix]
106-# Every nix command in the container reads the mounted cache, including one
107-# typed by hand in a shell. Passing --extra-substituters per command only ever
108-# covered the scripts.
109-substituters = ["file:///nix-cache"]
110-# No devShell: we want `nix build`, not a shell to run something inside, so
111-# there is nothing to warm at build time and no reason for the ptyshim. The
112-# build happens at run time, in the Sandbox, on a real VM -- which is the
113-# whole reason it can realise a derivation at all.
114-flake = false
115-shim = false
93+# The same script `just desktop` runs, and the same single action: what a
94+# laptop assembles and what this ships are the same directory.
95+tools/build-desktop.sh tar
11696
117-# [experimental] overrides the sandbox default of vm_runtime = true.
118-# Setting it here turns that default off, so leave it alone unless you
119-# want gVisor.
97+# Where the runner fetches it from, the way the AppImage was left in the
98+# nix-cache volume before. One file, already a squashed tree.
99+cp -L build/frq-desktop-x86_64-linux.tar.gz /devshell/artifacts/
100+ls -la /devshell/artifacts/frq-desktop-x86_64-linux.tar.gz
101+"""
102+# Set here rather than in the command so a shell into this container gets them
103+# too. FRQ_JOLT_NATIVE_SHA is the one pin the script will not invent: until
104+# jolt-native's pipeline has published a portable tarball for the revision
105+# tools/desktop-toolchain.sh names, this container fails fast and says so.
106+env = { FRQ_DESKTOP_TOOLCHAIN = "/devshell/frq-desktop.toolchain" }
@@ -1,9 +1,22 @@
1 [container]1 [container]
2 name = "frq"2 name = "frq"
3-description = "nix build .#appimage -- the cosmic GUI, in one file"3+description = "the cosmic desktop bundle, from a pinned toolchain"
4-base = "arch-nix"4+# `debian:13-slim` and not `arch-nix`: there is no nix in this container any
5-# A Sandbox, not a Function: runs on a real VM, and the command is5+# more, and the AppImage it used to build is gone with it. What this assembles
6-# the sandbox's own process so it dies when the command does.6+# is a directory — a jolt binary, the native objects, the source and a
7+# launcher — and what it owes that is curl, git, tar, a C compiler and
8+# openh264's headers.
9+#
10+# The reason this could stop being a nix build is not that nix was doing it
11+# badly. It is that everything it was building from source is now published as
12+# something a machine without nix can use: jolt as one statically-linked
13+# binary, the backends as jolt-native's `portable` tarball (RUNPATH $ORIGIN,
14+# NEEDED closure alongside), libmoq_ffi as an upstream release object. What
15+# was left was a Mesa and a nixGL to put the host driver in front of it —
16+# and not carrying a Mesa is what means not needing a nixGL.
17+registry = "debian:13-slim"
18+# A Sandbox, not a Function: it runs on a real VM and the command is the
19+# sandbox's own process, so it dies when the command does.
7 runtime = "sandbox"20 runtime = "sandbox"
8 21
9 [build]22 [build]
@@ -12,108 +25,82 @@ runtime = "sandbox"
12 # the point of copying rather than fetching.25 # the point of copying rather than fetching.
13 context = "../.."26 context = "../.."
14 include = ["."]27 include = ["."]
15-# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on28+# One apt line, where there used to be a nix store to populate. gcc and
16-# every start and wanted by nothing out there. Flutter builds into a volume of29+# pkg-config for `c/frq_h264.c`, which is the only thing here that compiles;
17-# its own, and the jolt and clojure caches are this machine's.30+# libopenh264-dev for its header and link; libopus and libasound because the
31+# bundle carries them out to the machine that runs it. patchelf to set
32+# $ORIGIN on the objects that arrive without it. git for the two pinned source
33+# checkouts, ca-certificates so curl can verify what it fetches.
34+setup = [
35+ "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git gcc libc6-dev pkg-config libopenh264-dev libopus-dev libasound2-dev patchelf rsync tar xz-utils && rm -rf /var/lib/apt/lists/*",
36+]
37+# The build state a local checkout carries, wanted by nothing out here.
18 ignore = [38 ignore = [
19 "flutter/build", "flutter/.home", "flutter/.dart_tool",39 "flutter/build", "flutter/.home", "flutter/.dart_tool",
20- "flutter/.clojuredart", "flutter/.cpcache",40+ "flutter/.clojuredart", "flutter/.cpcache", "flutter/lib/cljd-out",
41+ # The toolchains, which live on the volume out here.
42+ ".toolchain", ".toolchain-desktop",
21 ".jolt", ".cpcache", "result", "build", ".git",43 ".jolt", ".cpcache", "result", "build", ".git",
44+ # An editor's linter rewrites this while the upload is reading it, and
45+ # Modal fails the whole run with "was modified during build process".
46+ ".clj-kondo",
22 ]47 ]
23 48
24-# The Modal Volume that makes a second build cheap. Mounted at run time, which49+# `devshell` and not `nix-cache`, which went with nix — the same volume every
25-# is when the build happens here -- nothing is written to it while the image is50+# other container's working state lives on, under a directory named for this
26-# built, because a volume mount is not part of the resulting image.51+# one. The toolchain is what makes a second run cheap now: ~30MB of jolt and
52+# objects fetched once and found there afterwards.
27 [volumes]53 [volumes]
28-nix-cache = "/nix-cache"54+devshell = "/devshell"
29 55
30 [resources]56 [resources]
31-cpu = 857+# Four, not eight: nothing here compiles except one .c file. What this job
32-memory = 1638458+# spends its time on is fetching and copying.
33-# libjoltcosmic's tree is the long pole even when most of it substitutes.59+cpu = 4
34-timeout = 360060+memory = 8192
61+# Minutes, not an hour. The old timeout was sized for libjoltcosmic's
62+# dependency tree, which is now somebody else's pipeline's problem.
63+timeout = 900
35 64
36 [run]65 [run]
37 workdir = "/app"66 workdir = "/app"
38-# The build, not the window: `#appimage` bundles `#frq` -- the cosmic GUI,67+# Source in, toolchain out of the volume, assemble, tar. The old command was a
39-# whose frqScript runs `-m frq.cosmic` -- and nothing here tries to open it.68+# `nix build` of a closure with ~200 store paths in it, a substituter test to
40-# There is no GL and no display on a build box, which is why this only builds.69+# decide whether writing the cache back was worth it, and a copy of the
41-#70+# AppImage into the volume. This is four steps and none of them evaluates
42-# `#appimage` rather than `#frq` because of how the result gets home. A store71+# anything.
43-# path is only useful to a machine that can take its whole closure, which
44-# means walking ~200 narinfos out of the volume one call at a time and then
45-# importing them. The AppImage is that same closure squashed into one file:
46-# one `modal volume get`, no store import, and it runs off NixOS because the
47-# bundle carries the Mesa that nixGL puts the host driver in front of.
48-#
49-# `path:/app` and not `.`: a checkout copied in here brings its `.git` with
50-# it, and in a worktree that is a *file* naming a gitdir back on the host.
51-# Nix believes it, tries to open a repository that is not there, and fails
52-# before it evaluates anything. `path:` says plain directory and means it.
53-#
54-# The cache is read as a substituter and written with `nix copy --all`, but
55-# only when this run actually produced something the cache has not got. That
56-# test is the difference between a nine-minute run and a two-minute one: on a
57-# full hit `--all` still interrogates every one of the cache's thousands of
58-# paths to discover it has nothing to write, and that interrogation cost more
59-# than the build it exists to avoid. Asking whether the result's own narinfo
60-# is already there answers the same question in one stat.
61-#
62-# `--max-jobs auto` on the command line and not only in nix.conf: the base
63-# image carries that setting now, but only from the next `modal run
64-# arch_nix.py` onwards, and the flag costs nothing once it is redundant.
65-# Nix's default is 1 -- the whole graph end to end, one derivation at a time.
66-#
67-# `set -e` earns its place: the last command here is a `ls`, so without it a
68-# failed `nix build` would still leave the sandbox exiting 0 and the run would
69-# report success. The substituter test is an `if` rather than `&&` for the same
70-# reason -- under `set -e` a false `&&` would abort the whole script on the
71-# first run, when there is legitimately nothing in the cache yet.
72 command = """72 command = """
73 set -e73 set -e
74-mkdir -p /nix-cache/artifacts74+BUILD_DIR=/devshell/frq-desktop
75-subs=""
76-if [ -f /nix-cache/nix-cache-info ]; then
77- subs="--extra-substituters file:///nix-cache"
78- echo "cache: reading from /nix-cache"
79-else
80- echo "cache: empty, this run fills it"
81-fi
82-# One setting per invocation -- `nix config show a b` is an argument error,
83-# and under `set -e` that kills the run before it builds anything.
84-nix config show max-jobs
85-nix config show cores
86 75
87-out=$(nix build path:/app#appimage --accept-flake-config $subs \76+# Beside the working tree and NOT inside it: the rsync below runs with
88- --max-jobs auto --cores 0 --print-out-paths --print-build-logs)77+# --delete, so anything under $BUILD_DIR that is not in /app is removed on
89-echo "built: $out"78+# every run. A toolchain kept in there would be deleted moments before it was
79+# consulted.
80+export FRQ_DESKTOP_TOOLCHAIN=/devshell/frq-desktop.toolchain
81+mkdir -p "$BUILD_DIR" "$FRQ_DESKTOP_TOOLCHAIN" /devshell/artifacts
90 82
91-cp -L "$out" /nix-cache/artifacts/frq.AppImage83+echo "sync: /app -> $BUILD_DIR"
92-chmod +x /nix-cache/artifacts/frq.AppImage84+rsync -a --checksum --delete \
93-ls -la /nix-cache/artifacts/frq.AppImage85+ --exclude '.toolchain/' \
86+ --exclude '.toolchain-desktop/' \
87+ --exclude '.git' \
88+ /app/ "$BUILD_DIR/"
94 89
95-hash=$(basename "$out" | cut -d- -f1)90+cd "$BUILD_DIR"
96-if [ -f "/nix-cache/$hash.narinfo" ]; then91+[ -d "$FRQ_DESKTOP_TOOLCHAIN/jolt" ] && echo " carried over: the toolchain"
97- echo "cache: $hash already held, nothing to write"
98-else
99- echo "cache: writing the store back"
100- nix copy --no-check-sigs --all --to file:///nix-cache
101-fi
102-"""
103-env = { }
104 92
105-[nix]93+# The same script `just desktop` runs, and the same single action: what a
106-# Every nix command in the container reads the mounted cache, including one94+# laptop assembles and what this ships are the same directory.
107-# typed by hand in a shell. Passing --extra-substituters per command only ever95+tools/build-desktop.sh tar
108-# covered the scripts.
109-substituters = ["file:///nix-cache"]
110-# No devShell: we want `nix build`, not a shell to run something inside, so
111-# there is nothing to warm at build time and no reason for the ptyshim. The
112-# build happens at run time, in the Sandbox, on a real VM -- which is the
113-# whole reason it can realise a derivation at all.
114-flake = false
115-shim = false
116 96
117-# [experimental] overrides the sandbox default of vm_runtime = true.97+# Where the runner fetches it from, the way the AppImage was left in the
118-# Setting it here turns that default off, so leave it alone unless you98+# nix-cache volume before. One file, already a squashed tree.
119-# want gVisor.99+cp -L build/frq-desktop-x86_64-linux.tar.gz /devshell/artifacts/
100+ls -la /devshell/artifacts/frq-desktop-x86_64-linux.tar.gz
101+"""
102+# Set here rather than in the command so a shell into this container gets them
103+# too. FRQ_JOLT_NATIVE_SHA is the one pin the script will not invent: until
104+# jolt-native's pipeline has published a portable tarball for the revision
105+# tools/desktop-toolchain.sh names, this container fails fast and says so.
106+env = { FRQ_DESKTOP_TOOLCHAIN = "/devshell/frq-desktop.toolchain" }
modified .rickub/workflows/build.yml +30 -21
@@ -1,14 +1,19 @@
11 # The build, on rickub. GitLab CI next door reads source and no more —
22 # check-common on every push, the flake.lock nightly — and deliberately builds
3-# nothing. This is the other half: the AppImage, actually realised, on a
4-# machine that can hold libjoltcosmic's dependency tree.
3+# nothing. This is the other half: the desktop bundle, actually assembled.
54 #
6-# It does not realise it *here*. rickub runners are Linux x86-64 containers,
7-# and `nix build .#appimage` on one is the same laptop-shaped death CLAUDE.md
8-# warns about — so this job is a client. It hands the build to Modal, exactly
9-# as a person at a terminal would, and the Sandbox on its real VM does the
10-# work against the nix-cache volume. What a runner contributes is a checkout,
11-# a python, and somewhere to put the result afterwards.
5+# It is not assembled *here*, though it nearly could be now. The job hands the
6+# work to Modal exactly as a person at a terminal would, and the Sandbox does
7+# it against the `devshell` volume. What a runner contributes is a checkout, a
8+# python, and somewhere to put the result afterwards.
9+#
10+# It used to be `nix build .#appimage`, which a rickub runner could not have
11+# done at all — a container against libjoltcosmic's dependency tree is the
12+# laptop-shaped death CLAUDE.md warns about. That is no longer what the
13+# container runs: jolt, the backends and libmoq_ffi all arrive pinned and
14+# prebuilt, so the Modal side fetches and copies rather than compiling. The
15+# reason it still goes to Modal is the volume the toolchain is cached on, not
16+# the size of the build.
1217 #
1318 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub
1419 # reads one or the other and never both: with this directory present, a
@@ -33,12 +38,13 @@ jobs:
3338 - uses: actions/checkout@v4
3439 - run: python3 tools/check-common.py common
3540
36- appimage:
41+ desktop:
3742 runs-on: ubuntu-latest
3843 needs: check-common
39- # The Modal side is an hour at its worst (a cold libjoltcosmic); this side
40- # is waiting on it, plus the upload of the tree.
41- timeout-minutes: 90
44+ # Minutes now, not an hour. The Modal side compiles one .c file; what it
45+ # spends its time on is fetching the pinned pieces on a cold toolchain and
46+ # the upload of the tree from here.
47+ timeout-minutes: 30
4248 steps:
4349 # The container copies `.` — the whole working tree, uncommitted edits
4450 # included. On a runner that is whatever the checkout left, so it wants
@@ -54,7 +60,7 @@ jobs:
5460 # Two secrets, set under Settings -> Secrets and variables. A Modal
5561 # token is the whole of this job's configuration: no nix, no builder,
5662 # no cache of its own.
57- - name: nix build .#appimage, on Modal
63+ - name: Assemble the desktop bundle, on Modal
5864 env:
5965 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
6066 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
@@ -64,20 +70,23 @@ jobs:
6470 # tail: a run killed mid-pipe through tail takes its output with it.
6571 run: modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log
6672
67- # The Sandbox leaves the AppImage in the nix-cache volume rather than
68- # anywhere a runner can see, so fetch it back out. Same one file the
69- # build copied there — a squashed closure, not a store path needing an
70- # import on the other end.
71- - name: Fetch the AppImage out of the volume
73+ # The Sandbox leaves the tarball on the devshell volume rather than
74+ # anywhere a runner can see, so fetch it back out. One file, already a
75+ # squashed tree — nothing to import on the other end, and nothing to
76+ # unpack before it can be uploaded.
77+ - name: Fetch the bundle out of the volume
7278 env:
7379 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
7480 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
75- run: modal volume get --force nix-cache artifacts/frq.AppImage frq.AppImage
81+ run: |
82+ modal volume get --force devshell \
83+ artifacts/frq-desktop-x86_64-linux.tar.gz \
84+ frq-desktop-x86_64-linux.tar.gz
7685
7786 - uses: actions/upload-artifact@v4
7887 with:
79- name: frq-AppImage-${{ github.sha }}
80- path: frq.AppImage
88+ name: frq-desktop-${{ github.sha }}
89+ path: frq-desktop-x86_64-linux.tar.gz
8190 if-no-files-found: error
8291
8392 # Kept whether or not the build succeeded: a failed run's log is the
@@ -1,14 +1,19 @@
1 # The build, on rickub. GitLab CI next door reads source and no more —1 # The build, on rickub. GitLab CI next door reads source and no more —
2 # check-common on every push, the flake.lock nightly — and deliberately builds2 # check-common on every push, the flake.lock nightly — and deliberately builds
3-# nothing. This is the other half: the AppImage, actually realised, on a3+# nothing. This is the other half: the desktop bundle, actually assembled.
4-# machine that can hold libjoltcosmic's dependency tree.
5 #4 #
6-# It does not realise it *here*. rickub runners are Linux x86-64 containers,5+# It is not assembled *here*, though it nearly could be now. The job hands the
7-# and `nix build .#appimage` on one is the same laptop-shaped death CLAUDE.md6+# work to Modal exactly as a person at a terminal would, and the Sandbox does
8-# warns about — so this job is a client. It hands the build to Modal, exactly7+# it against the `devshell` volume. What a runner contributes is a checkout, a
9-# as a person at a terminal would, and the Sandbox on its real VM does the8+# python, and somewhere to put the result afterwards.
10-# work against the nix-cache volume. What a runner contributes is a checkout,9+#
11-# a python, and somewhere to put the result afterwards.10+# It used to be `nix build .#appimage`, which a rickub runner could not have
11+# done at all — a container against libjoltcosmic's dependency tree is the
12+# laptop-shaped death CLAUDE.md warns about. That is no longer what the
13+# container runs: jolt, the backends and libmoq_ffi all arrive pinned and
14+# prebuilt, so the Modal side fetches and copies rather than compiling. The
15+# reason it still goes to Modal is the volume the toolchain is cached on, not
16+# the size of the build.
12 #17 #
13 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub18 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub
14 # reads one or the other and never both: with this directory present, a19 # reads one or the other and never both: with this directory present, a
@@ -33,12 +38,13 @@ jobs:
33 - uses: actions/checkout@v438 - uses: actions/checkout@v4
34 - run: python3 tools/check-common.py common39 - run: python3 tools/check-common.py common
35 40
36- appimage:41+ desktop:
37 runs-on: ubuntu-latest42 runs-on: ubuntu-latest
38 needs: check-common43 needs: check-common
39- # The Modal side is an hour at its worst (a cold libjoltcosmic); this side44+ # Minutes now, not an hour. The Modal side compiles one .c file; what it
40- # is waiting on it, plus the upload of the tree.45+ # spends its time on is fetching the pinned pieces on a cold toolchain and
41- timeout-minutes: 9046+ # the upload of the tree from here.
47+ timeout-minutes: 30
42 steps:48 steps:
43 # The container copies `.` — the whole working tree, uncommitted edits49 # The container copies `.` — the whole working tree, uncommitted edits
44 # included. On a runner that is whatever the checkout left, so it wants50 # included. On a runner that is whatever the checkout left, so it wants
@@ -54,7 +60,7 @@ jobs:
54 # Two secrets, set under Settings -> Secrets and variables. A Modal60 # Two secrets, set under Settings -> Secrets and variables. A Modal
55 # token is the whole of this job's configuration: no nix, no builder,61 # token is the whole of this job's configuration: no nix, no builder,
56 # no cache of its own.62 # no cache of its own.
57- - name: nix build .#appimage, on Modal63+ - name: Assemble the desktop bundle, on Modal
58 env:64 env:
59 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}65 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
60 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}66 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
@@ -64,20 +70,23 @@ jobs:
64 # tail: a run killed mid-pipe through tail takes its output with it.70 # tail: a run killed mid-pipe through tail takes its output with it.
65 run: modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log71 run: modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log
66 72
67- # The Sandbox leaves the AppImage in the nix-cache volume rather than73+ # The Sandbox leaves the tarball on the devshell volume rather than
68- # anywhere a runner can see, so fetch it back out. Same one file the74+ # anywhere a runner can see, so fetch it back out. One file, already a
69- # build copied there — a squashed closure, not a store path needing an75+ # squashed tree — nothing to import on the other end, and nothing to
70- # import on the other end.76+ # unpack before it can be uploaded.
71- - name: Fetch the AppImage out of the volume77+ - name: Fetch the bundle out of the volume
72 env:78 env:
73 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}79 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
74 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}80 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
75- run: modal volume get --force nix-cache artifacts/frq.AppImage frq.AppImage81+ run: |
82+ modal volume get --force devshell \
83+ artifacts/frq-desktop-x86_64-linux.tar.gz \
84+ frq-desktop-x86_64-linux.tar.gz
76 85
77 - uses: actions/upload-artifact@v486 - uses: actions/upload-artifact@v4
78 with:87 with:
79- name: frq-AppImage-${{ github.sha }}88+ name: frq-desktop-${{ github.sha }}
80- path: frq.AppImage89+ path: frq-desktop-x86_64-linux.tar.gz
81 if-no-files-found: error90 if-no-files-found: error
82 91
83 # Kept whether or not the build succeeded: a failed run's log is the92 # Kept whether or not the build succeeded: a failed run's log is the
modified CLAUDE.md +1 -1
@@ -9,7 +9,7 @@ memory long before it finishes, and the minutes spent finding that out are
99 minutes not spent on the change. So:
1010
1111 ```bash
12-modal run .modal/frq/container.py # .#appimage, on Modal
12+modal run .modal/frq/container.py # the desktop bundle, on Modal
1313 ```
1414
1515 The container is `.modal/`, not a fourth source tree: it is CI config that
@@ -9,7 +9,7 @@ memory long before it finishes, and the minutes spent finding that out are
9 minutes not spent on the change. So:9 minutes not spent on the change. So:
10 10
11 ```bash11 ```bash
12-modal run .modal/frq/container.py # .#appimage, on Modal12+modal run .modal/frq/container.py # the desktop bundle, on Modal
13 ```13 ```
14 14
15 The container is `.modal/`, not a fourth source tree: it is CI config that15 The container is `.modal/`, not a fourth source tree: it is CI config that
modified justfile +32 -3
@@ -462,15 +462,44 @@ flutter-web action="build" port="8080":
462462 # start would be one dependency away from the point.
463463 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}
464464
465+# The cosmic GUI as a directory anyone can unpack, with no nix on either end.
466+#
467+# `just cosmic run` is the edit loop — a devShell, this tree's source, a store
468+# path per dependency. This is the other end of the same program: a jolt
469+# binary, the backends out of jolt-native's portable tarball, libmoq_ffi off
470+# its release, glimmer and glimmer-cosmic at pinned revs, and one .c file
471+# compiled on the spot. `tools/desktop-toolchain.sh` fetches; nothing is
472+# built from source that somebody else has already published.
473+#
474+# It replaces `nix build .#appimage`, and what it drops with it is the reason
475+# that output existed. nix-appimage squashed a closure into one file so a
476+# machine without nix could run it, and the heaviest thing in that closure was
477+# a Mesa — carried so that nixGL had something to put the host driver in front
478+# of. There is no Mesa here, so there is no nixGL: the GL driver is the
479+# host's, the way it is for everything else on the machine.
480+#
481+# just desktop assemble build/desktop
482+# just desktop tar ...and tar it up for another machine
483+# just desktop run ...and start it
484+desktop action="build":
485+ #!/usr/bin/env bash
486+ set -euo pipefail
487+ # A wrapper and nothing else, for the reason `flutter-web` is one: the
488+ # container runs the same script, and a container that had to install
489+ # `just` first would be one dependency away from the point.
490+ exec "{{justfile_directory()}}/tools/build-desktop.sh" {{action}}
491+
465492 # The containers in `.modal/`, run on Modal rather than here. This machine
466493 # evaluates and Modal builds — see CLAUDE.md, which says so rather more
467494 # firmly — and these two recipes are the whole interface to that.
468495 #
469496 # Named for where the work happens, the way `cosmic` and `flutter-desktop`
470497 # are named for what paints: there is no re-entry test here because nothing
471-# re-enters, and no `nix` variable because nix runs out there.
498+# re-enters. There is no `nix` variable either, and that used to be because
499+# nix ran out there — now it is because two of these three containers have no
500+# nix in them at all.
472501 #
473-# just modal frq build `.#appimage` on Modal
502+# just modal frq assemble the desktop bundle on Modal
474503 # just modal flutter-dev the incremental Flutter loop
475504 modal container="frq" *args:
476505 #!/usr/bin/env bash
@@ -528,7 +557,7 @@ web-local port="8080":
528557 # the sandbox bills until you do.
529558 #
530559 # just modal-shell flutter-dev, the usual one
531-# just modal-shell frq the appimage container
560+# just modal-shell frq the desktop bundle container
532561 modal-shell container="flutter-dev":
533562 #!/usr/bin/env bash
534563 set -euo pipefail
@@ -462,15 +462,44 @@ flutter-web action="build" port="8080":
462 # start would be one dependency away from the point.462 # start would be one dependency away from the point.
463 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}463 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}
464 464
465+# The cosmic GUI as a directory anyone can unpack, with no nix on either end.
466+#
467+# `just cosmic run` is the edit loop — a devShell, this tree's source, a store
468+# path per dependency. This is the other end of the same program: a jolt
469+# binary, the backends out of jolt-native's portable tarball, libmoq_ffi off
470+# its release, glimmer and glimmer-cosmic at pinned revs, and one .c file
471+# compiled on the spot. `tools/desktop-toolchain.sh` fetches; nothing is
472+# built from source that somebody else has already published.
473+#
474+# It replaces `nix build .#appimage`, and what it drops with it is the reason
475+# that output existed. nix-appimage squashed a closure into one file so a
476+# machine without nix could run it, and the heaviest thing in that closure was
477+# a Mesa — carried so that nixGL had something to put the host driver in front
478+# of. There is no Mesa here, so there is no nixGL: the GL driver is the
479+# host's, the way it is for everything else on the machine.
480+#
481+# just desktop assemble build/desktop
482+# just desktop tar ...and tar it up for another machine
483+# just desktop run ...and start it
484+desktop action="build":
485+ #!/usr/bin/env bash
486+ set -euo pipefail
487+ # A wrapper and nothing else, for the reason `flutter-web` is one: the
488+ # container runs the same script, and a container that had to install
489+ # `just` first would be one dependency away from the point.
490+ exec "{{justfile_directory()}}/tools/build-desktop.sh" {{action}}
491+
465 # The containers in `.modal/`, run on Modal rather than here. This machine492 # The containers in `.modal/`, run on Modal rather than here. This machine
466 # evaluates and Modal builds — see CLAUDE.md, which says so rather more493 # evaluates and Modal builds — see CLAUDE.md, which says so rather more
467 # firmly — and these two recipes are the whole interface to that.494 # firmly — and these two recipes are the whole interface to that.
468 #495 #
469 # Named for where the work happens, the way `cosmic` and `flutter-desktop`496 # Named for where the work happens, the way `cosmic` and `flutter-desktop`
470 # are named for what paints: there is no re-entry test here because nothing497 # are named for what paints: there is no re-entry test here because nothing
471-# re-enters, and no `nix` variable because nix runs out there.498+# re-enters. There is no `nix` variable either, and that used to be because
499+# nix ran out there — now it is because two of these three containers have no
500+# nix in them at all.
472 #501 #
473-# just modal frq build `.#appimage` on Modal502+# just modal frq assemble the desktop bundle on Modal
474 # just modal flutter-dev the incremental Flutter loop503 # just modal flutter-dev the incremental Flutter loop
475 modal container="frq" *args:504 modal container="frq" *args:
476 #!/usr/bin/env bash505 #!/usr/bin/env bash
@@ -528,7 +557,7 @@ web-local port="8080":
528 # the sandbox bills until you do.557 # the sandbox bills until you do.
529 #558 #
530 # just modal-shell flutter-dev, the usual one559 # just modal-shell flutter-dev, the usual one
531-# just modal-shell frq the appimage container560+# just modal-shell frq the desktop bundle container
532 modal-shell container="flutter-dev":561 modal-shell container="flutter-dev":
533 #!/usr/bin/env bash562 #!/usr/bin/env bash
534 set -euo pipefail563 set -euo pipefail
added tools/build-desktop.sh +145 -0
new file mode 100755
@@ -0,0 +1,145 @@
1+#!/usr/bin/env bash
2+# The cosmic desktop build: stage a runtime, its objects and the source next
3+# to each other, and write a launcher that starts them.
4+#
5+# There is no compilation here of anything written in Jolt — jolt reads
6+# deps.edn and the source at startup, which is what `nix build .#frq` was also
7+# doing behind a wrapper script. What that flake output added was a closure:
8+# a Mesa, a nixGL to put the host's driver in front of it, and a store path
9+# per dependency. `.#appimage` then squashed the lot into one file so a
10+# machine without nix could run it.
11+#
12+# This builds the same program without any of that. The pieces arrive pinned
13+# from `tools/desktop-toolchain.sh`, the one thing that IS compiled is a
14+# single .c file, and what comes out is a directory that runs from wherever
15+# it is unpacked.
16+#
17+# tools/build-desktop.sh build build/desktop
18+# tools/build-desktop.sh tar ...and tar it up beside itself
19+# tools/build-desktop.sh run ...and start it
20+#
21+# The container in `.modal/frq/` runs this same file, the way
22+# `.modal/flutter-web/` runs tools/build-web.sh.
23+set -euo pipefail
24+
25+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26+action="${1:-build}"
27+
28+case "$action" in build|tar|run) ;; *)
29+ echo "usage: build-desktop.sh [build|tar|run]" >&2; exit 1 ;;
30+esac
31+
32+eval "$("$root/tools/desktop-toolchain.sh" env)"
33+
34+out="${FRQ_DESKTOP_OUT:-$root/build/desktop}"
35+rm -rf "$out"
36+mkdir -p "$out/bin" "$out/lib" "$out/src"
37+
38+# The runtime.
39+install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/jolt/jolt" "$out/bin/jolt"
40+
41+# Every object in one directory, because JOLT_NATIVE_LIB is one directory:
42+# jolt resolves each `:jolt/native` name against it. They arrive from two
43+# places — the portable tarball and the moq-ffi release — which is exactly
44+# what `nativeAll` was a symlinkJoin for.
45+cp -a "$FRQ_DESKTOP_TOOLCHAIN/jolt-native/lib/." "$out/lib/"
46+install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/moq-ffi/lib/libmoq_ffi.so" "$out/lib/"
47+
48+# The calling-convention adapter, compiled here because it is one translation
49+# unit and because openh264's C API cannot be called from Jolt directly:
50+# `ISVCEncoder` is a `const ISVCEncoderVtbl*`, so `c/frq_h264.c` walks the
51+# vtable and exports five plain symbols. See src/frq/codec/h264.clj.
52+#
53+# This is the one place the build machine's own libraries get in, and the
54+# reason it is acceptable is the reason the portable tarball works at all:
55+# openh264, opus and alsa-lib are plain C libraries against glibc, and the
56+# copy below takes the .so the link actually resolved rather than trusting the
57+# runner to have the same one.
58+echo "desktop: compiling the h264 adapter" >&2
59+cc -O2 -fPIC -shared "$root/c/frq_h264.c" -o "$out/lib/libfrqh264.so" \
60+ $(pkg-config --cflags --libs openh264)
61+
62+# openh264 itself, opus, and ALSA's client library, beside it. `ldd` on what
63+# was just linked names the file the loader chose, which is the one to take —
64+# a guess at a soname is a guess at the distro.
65+for soname in libopenh264 libopus libasound; do
66+ lib=$(ldd "$out/lib/libfrqh264.so" 2>/dev/null | awk -v n="$soname" '$1 ~ "^"n"\\." {print $3; exit}')
67+ # opus and alsa are opened by jolt rather than NEEDED by the adapter, so
68+ # they are not in that ldd and have to be looked up.
69+ [ -n "${lib:-}" ] || lib=$(ldconfig -p | awk -v n="$soname" '$1 ~ "^"n"\\.so" {print $NF; exit}')
70+ if [ -z "${lib:-}" ]; then
71+ echo "desktop: no $soname on this machine — the AV plane will not load" >&2
72+ continue
73+ fi
74+ install -m 0755 -T "$lib" "$out/lib/$(basename "$lib")"
75+done
76+
77+# $ORIGIN for everything staged here, for the reason jolt-native's
78+# libsPortable sets it: the consumer decides where this unpacks and only the
79+# loader knows where that turned out to be. The objects out of the portable
80+# tarball already have it; the ones added above do not.
81+if command -v patchelf >/dev/null 2>&1; then
82+ for f in "$out/lib"/*.so*; do patchelf --set-rpath '$ORIGIN' "$f" 2>/dev/null || true; done
83+else
84+ echo "desktop: no patchelf; the launcher's LD_LIBRARY_PATH covers this" >&2
85+fi
86+
87+# The project as jolt sees it: source, deps.edn, nothing else — the same three
88+# things `frqSource` copied in the flake.
89+cp -a "$root/common" "$root/src" "$root/deps.edn" "$out/src/"
90+
91+# The two Jolt libraries the launcher names in -Sdeps. Copied in rather than
92+# referenced out of the toolchain, so the bundle is self-contained: a tarball
93+# that needs a directory from the machine that made it is not a bundle.
94+cp -a "$FRQ_GLIMMER" "$out/glimmer"
95+cp -a "$FRQ_GLIMMER_COSMIC" "$out/glimmer-cosmic"
96+rm -rf "$out/glimmer/.git" "$out/glimmer-cosmic/.git"
97+
98+# The launcher. `frqScript` from the flake, with the store paths replaced by
99+# $ORIGIN-relative ones and the nixGL branch deleted — off NixOS that existed
100+# to put the host's GL driver ahead of the closure's Mesa, and there is no
101+# Mesa in here to get ahead of. The driver is simply the host's.
102+cat > "$out/bin/frq" <<'LAUNCH'
103+#!/usr/bin/env bash
104+set -euo pipefail
105+here="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
106+
107+export JOLT_NATIVE_LIB="$here/lib"
108+export LD_LIBRARY_PATH="$here/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
109+
110+# ALSA finds its plugins by directory rather than by soname, and `default`
111+# resolves to nothing without the PipeWire one. Unlike the flake, which named
112+# a store path, this defers to the host: every machine frq targets runs
113+# PipeWire and ships that plugin in the usual place.
114+for d in /usr/lib/x86_64-linux-gnu/alsa-lib /usr/lib/alsa-lib /usr/lib64/alsa-lib; do
115+ [ -d "$d" ] && export ALSA_PLUGIN_DIR="$d" && break
116+done
117+
118+# jolt resolves deps.edn from the working directory.
119+cd "$here/src"
120+
121+exec "$here/bin/jolt" \
122+ -Sdeps "{:deps {jolt-lang/glimmer {:local/root \"$here/glimmer\"}
123+ nandi/glimmer-cosmic {:local/root \"$here/glimmer-cosmic\"}}}" \
124+ -m frq.cosmic "$@"
125+LAUNCH
126+chmod +x "$out/bin/frq"
127+
128+echo "desktop: built $out" >&2
129+du -sh "$out" >&2
130+
131+case "$action" in
132+ tar)
133+ tarball="${FRQ_DESKTOP_TAR:-$root/build/frq-desktop-x86_64-linux.tar.gz}"
134+ mkdir -p "$(dirname "$tarball")"
135+ # Rooted at a directory of its own, because a tarball that unpacks
136+ # `bin/` and `lib/` into the current directory is a tarball someone
137+ # will one day unpack into their home.
138+ tar czf "$tarball" -C "$(dirname "$out")" "$(basename "$out")"
139+ echo "desktop: $tarball" >&2
140+ ls -la "$tarball" >&2
141+ ;;
142+ run)
143+ exec "$out/bin/frq"
144+ ;;
145+esac
new file mode 100755
@@ -0,0 +1,145 @@
1+#!/usr/bin/env bash
2+# The cosmic desktop build: stage a runtime, its objects and the source next
3+# to each other, and write a launcher that starts them.
4+#
5+# There is no compilation here of anything written in Jolt — jolt reads
6+# deps.edn and the source at startup, which is what `nix build .#frq` was also
7+# doing behind a wrapper script. What that flake output added was a closure:
8+# a Mesa, a nixGL to put the host's driver in front of it, and a store path
9+# per dependency. `.#appimage` then squashed the lot into one file so a
10+# machine without nix could run it.
11+#
12+# This builds the same program without any of that. The pieces arrive pinned
13+# from `tools/desktop-toolchain.sh`, the one thing that IS compiled is a
14+# single .c file, and what comes out is a directory that runs from wherever
15+# it is unpacked.
16+#
17+# tools/build-desktop.sh build build/desktop
18+# tools/build-desktop.sh tar ...and tar it up beside itself
19+# tools/build-desktop.sh run ...and start it
20+#
21+# The container in `.modal/frq/` runs this same file, the way
22+# `.modal/flutter-web/` runs tools/build-web.sh.
23+set -euo pipefail
24+
25+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26+action="${1:-build}"
27+
28+case "$action" in build|tar|run) ;; *)
29+ echo "usage: build-desktop.sh [build|tar|run]" >&2; exit 1 ;;
30+esac
31+
32+eval "$("$root/tools/desktop-toolchain.sh" env)"
33+
34+out="${FRQ_DESKTOP_OUT:-$root/build/desktop}"
35+rm -rf "$out"
36+mkdir -p "$out/bin" "$out/lib" "$out/src"
37+
38+# The runtime.
39+install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/jolt/jolt" "$out/bin/jolt"
40+
41+# Every object in one directory, because JOLT_NATIVE_LIB is one directory:
42+# jolt resolves each `:jolt/native` name against it. They arrive from two
43+# places — the portable tarball and the moq-ffi release — which is exactly
44+# what `nativeAll` was a symlinkJoin for.
45+cp -a "$FRQ_DESKTOP_TOOLCHAIN/jolt-native/lib/." "$out/lib/"
46+install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/moq-ffi/lib/libmoq_ffi.so" "$out/lib/"
47+
48+# The calling-convention adapter, compiled here because it is one translation
49+# unit and because openh264's C API cannot be called from Jolt directly:
50+# `ISVCEncoder` is a `const ISVCEncoderVtbl*`, so `c/frq_h264.c` walks the
51+# vtable and exports five plain symbols. See src/frq/codec/h264.clj.
52+#
53+# This is the one place the build machine's own libraries get in, and the
54+# reason it is acceptable is the reason the portable tarball works at all:
55+# openh264, opus and alsa-lib are plain C libraries against glibc, and the
56+# copy below takes the .so the link actually resolved rather than trusting the
57+# runner to have the same one.
58+echo "desktop: compiling the h264 adapter" >&2
59+cc -O2 -fPIC -shared "$root/c/frq_h264.c" -o "$out/lib/libfrqh264.so" \
60+ $(pkg-config --cflags --libs openh264)
61+
62+# openh264 itself, opus, and ALSA's client library, beside it. `ldd` on what
63+# was just linked names the file the loader chose, which is the one to take —
64+# a guess at a soname is a guess at the distro.
65+for soname in libopenh264 libopus libasound; do
66+ lib=$(ldd "$out/lib/libfrqh264.so" 2>/dev/null | awk -v n="$soname" '$1 ~ "^"n"\\." {print $3; exit}')
67+ # opus and alsa are opened by jolt rather than NEEDED by the adapter, so
68+ # they are not in that ldd and have to be looked up.
69+ [ -n "${lib:-}" ] || lib=$(ldconfig -p | awk -v n="$soname" '$1 ~ "^"n"\\.so" {print $NF; exit}')
70+ if [ -z "${lib:-}" ]; then
71+ echo "desktop: no $soname on this machine — the AV plane will not load" >&2
72+ continue
73+ fi
74+ install -m 0755 -T "$lib" "$out/lib/$(basename "$lib")"
75+done
76+
77+# $ORIGIN for everything staged here, for the reason jolt-native's
78+# libsPortable sets it: the consumer decides where this unpacks and only the
79+# loader knows where that turned out to be. The objects out of the portable
80+# tarball already have it; the ones added above do not.
81+if command -v patchelf >/dev/null 2>&1; then
82+ for f in "$out/lib"/*.so*; do patchelf --set-rpath '$ORIGIN' "$f" 2>/dev/null || true; done
83+else
84+ echo "desktop: no patchelf; the launcher's LD_LIBRARY_PATH covers this" >&2
85+fi
86+
87+# The project as jolt sees it: source, deps.edn, nothing else — the same three
88+# things `frqSource` copied in the flake.
89+cp -a "$root/common" "$root/src" "$root/deps.edn" "$out/src/"
90+
91+# The two Jolt libraries the launcher names in -Sdeps. Copied in rather than
92+# referenced out of the toolchain, so the bundle is self-contained: a tarball
93+# that needs a directory from the machine that made it is not a bundle.
94+cp -a "$FRQ_GLIMMER" "$out/glimmer"
95+cp -a "$FRQ_GLIMMER_COSMIC" "$out/glimmer-cosmic"
96+rm -rf "$out/glimmer/.git" "$out/glimmer-cosmic/.git"
97+
98+# The launcher. `frqScript` from the flake, with the store paths replaced by
99+# $ORIGIN-relative ones and the nixGL branch deleted — off NixOS that existed
100+# to put the host's GL driver ahead of the closure's Mesa, and there is no
101+# Mesa in here to get ahead of. The driver is simply the host's.
102+cat > "$out/bin/frq" <<'LAUNCH'
103+#!/usr/bin/env bash
104+set -euo pipefail
105+here="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
106+
107+export JOLT_NATIVE_LIB="$here/lib"
108+export LD_LIBRARY_PATH="$here/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
109+
110+# ALSA finds its plugins by directory rather than by soname, and `default`
111+# resolves to nothing without the PipeWire one. Unlike the flake, which named
112+# a store path, this defers to the host: every machine frq targets runs
113+# PipeWire and ships that plugin in the usual place.
114+for d in /usr/lib/x86_64-linux-gnu/alsa-lib /usr/lib/alsa-lib /usr/lib64/alsa-lib; do
115+ [ -d "$d" ] && export ALSA_PLUGIN_DIR="$d" && break
116+done
117+
118+# jolt resolves deps.edn from the working directory.
119+cd "$here/src"
120+
121+exec "$here/bin/jolt" \
122+ -Sdeps "{:deps {jolt-lang/glimmer {:local/root \"$here/glimmer\"}
123+ nandi/glimmer-cosmic {:local/root \"$here/glimmer-cosmic\"}}}" \
124+ -m frq.cosmic "$@"
125+LAUNCH
126+chmod +x "$out/bin/frq"
127+
128+echo "desktop: built $out" >&2
129+du -sh "$out" >&2
130+
131+case "$action" in
132+ tar)
133+ tarball="${FRQ_DESKTOP_TAR:-$root/build/frq-desktop-x86_64-linux.tar.gz}"
134+ mkdir -p "$(dirname "$tarball")"
135+ # Rooted at a directory of its own, because a tarball that unpacks
136+ # `bin/` and `lib/` into the current directory is a tarball someone
137+ # will one day unpack into their home.
138+ tar czf "$tarball" -C "$(dirname "$out")" "$(basename "$out")"
139+ echo "desktop: $tarball" >&2
140+ ls -la "$tarball" >&2
141+ ;;
142+ run)
143+ exec "$out/bin/frq"
144+ ;;
145+esac
added tools/desktop-toolchain.sh +217 -0
new file mode 100755
@@ -0,0 +1,217 @@
1+#!/usr/bin/env bash
2+# The cosmic desktop build's toolchain: a jolt binary, the native objects, and
3+# the two Jolt libraries the launcher hands to `-Sdeps`. Each pinned, fetched
4+# into a directory, and that is the whole of it. No nix.
5+#
6+# This is `tools/toolchain.sh` for the other target. That one fetches a
7+# Flutter, a JDK and a Clojure CLI because the web build is a compiler run;
8+# this one fetches a runtime and its libraries because the cosmic build is not
9+# a compilation at all — jolt reads source at startup, so "building" frq for
10+# the desktop means putting the right files next to each other.
11+#
12+# What made this possible is that every piece is now published as something a
13+# machine without nix can use:
14+#
15+# jolt one binary, chez linked in statically. Stock /lib64
16+# interpreter, NEEDED libc and libm and nothing else.
17+# jolt-native the `portable` tarball — the backends with their NEEDED
18+# closure beside them and RUNPATH $ORIGIN. The plain
19+# x86_64-linux tarball is NOT this: those objects resolve
20+# through the builder's /nix/store and are for nix consumers.
21+# libmoq_ffi an upstream release object, no RUNPATH, needs libgcc_s.
22+# glimmer Jolt source. Read, not linked.
23+#
24+# What is NOT here, and has to be on the machine that RUNS the result: the GL
25+# driver, and glibc. That is deliberate and it is what replaces the AppImage —
26+# nix-appimage carried a Mesa, which is why the launcher needed a nixGL to put
27+# the host's driver in front of it. Carrying no Mesa needs no nixGL.
28+#
29+# tools/desktop-toolchain.sh fetch whatever is missing
30+# eval "$(tools/desktop-toolchain.sh env)" ...and set this shell up
31+# tools/desktop-toolchain.sh exec -- jolt --version
32+#
33+# `FRQ_DESKTOP_TOOLCHAIN` says where it lives; the default is `.toolchain-
34+# desktop/` at the top of the checkout, and the container points it at a
35+# volume so the fetch happens once across runs rather than once across
36+# containers.
37+set -euo pipefail
38+
39+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
40+TC="${FRQ_DESKTOP_TOOLCHAIN:-$root/.toolchain-desktop}"
41+
42+# The pins. A URL and a hash, and nothing resolved at run time — the same rule
43+# tools/toolchain.sh states and for the same reason.
44+
45+# The runtime. One binary out of the fork's release, and it really is one
46+# binary: `patchelf --print-needed` on it says libm and libc.
47+#
48+# To move it: tag a release in gitlab.com/nandithebull/jolt and take the URL
49+# and sha256 from its asset. The version string the binary prints is the tag,
50+# so a mismatch between this pin and the flake's `jolt-src` rev is visible in
51+# `jolt --version` rather than silent.
52+JOLT_VERSION="v0.7.28-1-g2b80d68d"
53+JOLT_URL="https://gitlab.com/-/project/85910549/uploads/c67e91d30934c404583c004710c249a6/jolt-${JOLT_VERSION}-x86_64-linux.tar.gz"
54+JOLT_SHA="aabb71f809aebd9d607b7a5036229f933c733ae90785cb29df899f116be9589f"
55+
56+# The native backends, and the Jolt source that binds them, at ONE revision.
57+#
58+# One variable for both on purpose. glimmer-cosmic talks to libjoltcosmic over
59+# a retained-tree ABI that is not versioned, and the flake's comment on the
60+# jolt-native input says what drift costs: the Jolt half sent a reaction
61+# pill's hover card to a backend with no handler for one, and the pill said
62+# nothing. The source and the object are the same commit here by construction.
63+JOLT_NATIVE_REV="65c27be020b52eb87d0c0718c8cfff1869e8d2f7"
64+JOLT_NATIVE_URL="https://gitlab.com/api/v4/projects/85910092/packages/generic/jolt-native/${JOLT_NATIVE_REV}/x86_64-linux-portable.tar.gz"
65+# Filled in from the first pipeline that publishes this revision's tarball:
66+#
67+# curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
68+#
69+# Left as the placeholder deliberately rather than omitted — an unpinned
70+# fetch of a URL under a package registry that serves the NEWEST upload for a
71+# given name is exactly the moving target these pins exist to refuse.
72+JOLT_NATIVE_SHA="${FRQ_JOLT_NATIVE_SHA:-0000000000000000000000000000000000000000000000000000000000000000}"
73+
74+# MoQ over QUIC behind UniFFI's C ABI, from upstream's release rather than
75+# built. Same object and same version the flake fetches.
76+MOQ_FFI_VERSION="0.3.17"
77+# The triple is the Rust one and not the nix system name -- `x86_64-linux`
78+# gets a 404 from this URL, which is how that got noticed.
79+MOQ_FFI_TARGET="x86_64-unknown-linux-gnu"
80+MOQ_FFI_URL="https://github.com/kixelated/moq/releases/download/moq-ffi-v${MOQ_FFI_VERSION}/moq-ffi-${MOQ_FFI_VERSION}-${MOQ_FFI_TARGET}-libmoq_ffi.so"
81+MOQ_FFI_SHA="773417a55e0981db43fa0df7597e7514501075f0945e436cc75c4f6e86cf7d42"
82+
83+# glimmer, at the rev deps.edn pins. Source, so it is cloned rather than
84+# fetched as an archive: a git rev is immutable in a way a forge's generated
85+# tarball is not — those are re-compressed across forge versions, and a
86+# sha256 over one is a pin that breaks without anything having changed.
87+GLIMMER_REPO="https://gitlab.com/nandithebull/glimmer.git"
88+GLIMMER_REV="399df371c790d690fb6e4560c3d4d7f838502857"
89+
90+JOLT_NATIVE_REPO="https://gitlab.com/nandithebull/jolt-native.git"
91+
92+# What the host still has to bring. Small and boring, but a missing one fails
93+# further from here with a worse message.
94+require_host_tools() {
95+ local missing=()
96+ for t in curl tar git sha256sum; do
97+ command -v "$t" >/dev/null 2>&1 || missing+=("$t")
98+ done
99+ if [ ${#missing[@]} -gt 0 ]; then
100+ echo "desktop-toolchain: this needs ${missing[*]} on PATH and cannot fetch them" >&2
101+ exit 1
102+ fi
103+}
104+
105+# One archive, unpacked once. The stamp holds the hash rather than the
106+# version, and the work happens in `.tmp` so a half-finished unpack is never
107+# mistaken for a finished one — `tools/toolchain.sh` explains at length.
108+install_archive() {
109+ local name=$1 url=$2 sha=$3 strip=$4
110+ local dest="$TC/$name" stamp="$TC/$name.sha256"
111+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
112+ return 0
113+ fi
114+ echo "desktop-toolchain: fetching $name" >&2
115+ local dl="$TC/.download.$name"
116+ rm -rf "$dest" "$dest.tmp" "$dl"
117+ mkdir -p "$dest.tmp"
118+ curl -fsSL --retry 3 -o "$dl" "$url"
119+ echo "$sha $dl" | sha256sum -c - >/dev/null
120+ tar -xf "$dl" -C "$dest.tmp" --strip-components="$strip"
121+ rm -f "$dl"
122+ mv "$dest.tmp" "$dest"
123+ echo "$sha" > "$stamp"
124+}
125+
126+# The same, for something that is one file rather than an archive.
127+install_file() {
128+ local name=$1 url=$2 sha=$3 into=$4
129+ local dest="$TC/$name" stamp="$TC/$name.sha256"
130+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
131+ return 0
132+ fi
133+ echo "desktop-toolchain: fetching $name" >&2
134+ rm -rf "$dest" "$dest.tmp"
135+ mkdir -p "$dest.tmp/$(dirname "$into")"
136+ curl -fsSL --retry 3 -o "$dest.tmp/$into" "$url"
137+ echo "$sha $dest.tmp/$into" | sha256sum -c - >/dev/null
138+ mv "$dest.tmp" "$dest"
139+ echo "$sha" > "$stamp"
140+}
141+
142+# Source, by revision. `git -c advice.detachedHead=false` because this is
143+# always a detached checkout and the advice is four lines of it per fetch.
144+install_source() {
145+ local name=$1 repo=$2 rev=$3
146+ local dest="$TC/$name" stamp="$TC/$name.rev"
147+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$rev" ]; then
148+ return 0
149+ fi
150+ echo "desktop-toolchain: cloning $name at ${rev:0:8}" >&2
151+ rm -rf "$dest" "$dest.tmp"
152+ # A rev is not a ref, so this is init-fetch rather than clone --branch:
153+ # `git clone --depth 1` cannot take a sha unless the server allows it, and
154+ # `fetch --depth 1 <sha>` is the form that works everywhere.
155+ mkdir -p "$dest.tmp"
156+ git -C "$dest.tmp" init -q
157+ git -C "$dest.tmp" remote add origin "$repo"
158+ git -C "$dest.tmp" fetch -q --depth 1 origin "$rev"
159+ git -C "$dest.tmp" -c advice.detachedHead=false checkout -q FETCH_HEAD
160+ mv "$dest.tmp" "$dest"
161+ echo "$rev" > "$stamp"
162+}
163+
164+check_pins() {
165+ if [ "$JOLT_NATIVE_SHA" = "0000000000000000000000000000000000000000000000000000000000000000" ]; then
166+ cat >&2 <<MSG
167+desktop-toolchain: the jolt-native portable tarball is not pinned yet.
168+
169+ It is published by the first pipeline to run on jolt-native's main at
170+ ${JOLT_NATIVE_REV:0:8}. Once it exists:
171+
172+ curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
173+
174+ and put that in JOLT_NATIVE_SHA here, or pass it for one run as
175+ FRQ_JOLT_NATIVE_SHA=<sha256>.
176+MSG
177+ exit 1
178+ fi
179+}
180+
181+install_all() {
182+ require_host_tools
183+ check_pins
184+ mkdir -p "$TC"
185+ # strip 1: the jolt tarball is rooted at a versioned directory.
186+ install_archive jolt "$JOLT_URL" "$JOLT_SHA" 1
187+ # strip 0: the portable tarball is rooted at lib/ and include/ already,
188+ # which is the shape a consumer is meant to take it in.
189+ install_archive jolt-native "$JOLT_NATIVE_URL" "$JOLT_NATIVE_SHA" 0
190+ install_file moq-ffi "$MOQ_FFI_URL" "$MOQ_FFI_SHA" lib/libmoq_ffi.so
191+ install_source glimmer "$GLIMMER_REPO" "$GLIMMER_REV"
192+ install_source jolt-native-src "$JOLT_NATIVE_REPO" "$JOLT_NATIVE_REV"
193+}
194+
195+# The environment, as shell.
196+#
197+# JOLT_NATIVE_LIB is how jolt resolves every `:jolt/native` name, and it wants
198+# ONE directory — the objects come from two places (the portable tarball and
199+# the moq-ffi release), so `tools/build-desktop.sh` stages them into one and
200+# this names where that landed.
201+print_env() {
202+ cat <<ENV
203+export FRQ_DESKTOP_TOOLCHAIN="$TC"
204+export JOLT_NATIVE_LIB="$TC/native"
205+export LD_LIBRARY_PATH="$TC/native\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}"
206+export FRQ_GLIMMER="$TC/glimmer"
207+export FRQ_GLIMMER_COSMIC="$TC/jolt-native-src/glimmer-backends/glimmer-cosmic"
208+export PATH="$TC/jolt:\$PATH"
209+ENV
210+}
211+
212+case "${1:-install}" in
213+ install) install_all ;;
214+ env) install_all; print_env ;;
215+ exec) install_all; eval "$(print_env)"; shift; [ "${1:-}" = "--" ] && shift; exec "$@" ;;
216+ *) echo "usage: desktop-toolchain.sh [install|env|exec -- cmd...]" >&2; exit 1 ;;
217+esac
new file mode 100755
@@ -0,0 +1,217 @@
1+#!/usr/bin/env bash
2+# The cosmic desktop build's toolchain: a jolt binary, the native objects, and
3+# the two Jolt libraries the launcher hands to `-Sdeps`. Each pinned, fetched
4+# into a directory, and that is the whole of it. No nix.
5+#
6+# This is `tools/toolchain.sh` for the other target. That one fetches a
7+# Flutter, a JDK and a Clojure CLI because the web build is a compiler run;
8+# this one fetches a runtime and its libraries because the cosmic build is not
9+# a compilation at all — jolt reads source at startup, so "building" frq for
10+# the desktop means putting the right files next to each other.
11+#
12+# What made this possible is that every piece is now published as something a
13+# machine without nix can use:
14+#
15+# jolt one binary, chez linked in statically. Stock /lib64
16+# interpreter, NEEDED libc and libm and nothing else.
17+# jolt-native the `portable` tarball — the backends with their NEEDED
18+# closure beside them and RUNPATH $ORIGIN. The plain
19+# x86_64-linux tarball is NOT this: those objects resolve
20+# through the builder's /nix/store and are for nix consumers.
21+# libmoq_ffi an upstream release object, no RUNPATH, needs libgcc_s.
22+# glimmer Jolt source. Read, not linked.
23+#
24+# What is NOT here, and has to be on the machine that RUNS the result: the GL
25+# driver, and glibc. That is deliberate and it is what replaces the AppImage —
26+# nix-appimage carried a Mesa, which is why the launcher needed a nixGL to put
27+# the host's driver in front of it. Carrying no Mesa needs no nixGL.
28+#
29+# tools/desktop-toolchain.sh fetch whatever is missing
30+# eval "$(tools/desktop-toolchain.sh env)" ...and set this shell up
31+# tools/desktop-toolchain.sh exec -- jolt --version
32+#
33+# `FRQ_DESKTOP_TOOLCHAIN` says where it lives; the default is `.toolchain-
34+# desktop/` at the top of the checkout, and the container points it at a
35+# volume so the fetch happens once across runs rather than once across
36+# containers.
37+set -euo pipefail
38+
39+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
40+TC="${FRQ_DESKTOP_TOOLCHAIN:-$root/.toolchain-desktop}"
41+
42+# The pins. A URL and a hash, and nothing resolved at run time — the same rule
43+# tools/toolchain.sh states and for the same reason.
44+
45+# The runtime. One binary out of the fork's release, and it really is one
46+# binary: `patchelf --print-needed` on it says libm and libc.
47+#
48+# To move it: tag a release in gitlab.com/nandithebull/jolt and take the URL
49+# and sha256 from its asset. The version string the binary prints is the tag,
50+# so a mismatch between this pin and the flake's `jolt-src` rev is visible in
51+# `jolt --version` rather than silent.
52+JOLT_VERSION="v0.7.28-1-g2b80d68d"
53+JOLT_URL="https://gitlab.com/-/project/85910549/uploads/c67e91d30934c404583c004710c249a6/jolt-${JOLT_VERSION}-x86_64-linux.tar.gz"
54+JOLT_SHA="aabb71f809aebd9d607b7a5036229f933c733ae90785cb29df899f116be9589f"
55+
56+# The native backends, and the Jolt source that binds them, at ONE revision.
57+#
58+# One variable for both on purpose. glimmer-cosmic talks to libjoltcosmic over
59+# a retained-tree ABI that is not versioned, and the flake's comment on the
60+# jolt-native input says what drift costs: the Jolt half sent a reaction
61+# pill's hover card to a backend with no handler for one, and the pill said
62+# nothing. The source and the object are the same commit here by construction.
63+JOLT_NATIVE_REV="65c27be020b52eb87d0c0718c8cfff1869e8d2f7"
64+JOLT_NATIVE_URL="https://gitlab.com/api/v4/projects/85910092/packages/generic/jolt-native/${JOLT_NATIVE_REV}/x86_64-linux-portable.tar.gz"
65+# Filled in from the first pipeline that publishes this revision's tarball:
66+#
67+# curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
68+#
69+# Left as the placeholder deliberately rather than omitted — an unpinned
70+# fetch of a URL under a package registry that serves the NEWEST upload for a
71+# given name is exactly the moving target these pins exist to refuse.
72+JOLT_NATIVE_SHA="${FRQ_JOLT_NATIVE_SHA:-0000000000000000000000000000000000000000000000000000000000000000}"
73+
74+# MoQ over QUIC behind UniFFI's C ABI, from upstream's release rather than
75+# built. Same object and same version the flake fetches.
76+MOQ_FFI_VERSION="0.3.17"
77+# The triple is the Rust one and not the nix system name -- `x86_64-linux`
78+# gets a 404 from this URL, which is how that got noticed.
79+MOQ_FFI_TARGET="x86_64-unknown-linux-gnu"
80+MOQ_FFI_URL="https://github.com/kixelated/moq/releases/download/moq-ffi-v${MOQ_FFI_VERSION}/moq-ffi-${MOQ_FFI_VERSION}-${MOQ_FFI_TARGET}-libmoq_ffi.so"
81+MOQ_FFI_SHA="773417a55e0981db43fa0df7597e7514501075f0945e436cc75c4f6e86cf7d42"
82+
83+# glimmer, at the rev deps.edn pins. Source, so it is cloned rather than
84+# fetched as an archive: a git rev is immutable in a way a forge's generated
85+# tarball is not — those are re-compressed across forge versions, and a
86+# sha256 over one is a pin that breaks without anything having changed.
87+GLIMMER_REPO="https://gitlab.com/nandithebull/glimmer.git"
88+GLIMMER_REV="399df371c790d690fb6e4560c3d4d7f838502857"
89+
90+JOLT_NATIVE_REPO="https://gitlab.com/nandithebull/jolt-native.git"
91+
92+# What the host still has to bring. Small and boring, but a missing one fails
93+# further from here with a worse message.
94+require_host_tools() {
95+ local missing=()
96+ for t in curl tar git sha256sum; do
97+ command -v "$t" >/dev/null 2>&1 || missing+=("$t")
98+ done
99+ if [ ${#missing[@]} -gt 0 ]; then
100+ echo "desktop-toolchain: this needs ${missing[*]} on PATH and cannot fetch them" >&2
101+ exit 1
102+ fi
103+}
104+
105+# One archive, unpacked once. The stamp holds the hash rather than the
106+# version, and the work happens in `.tmp` so a half-finished unpack is never
107+# mistaken for a finished one — `tools/toolchain.sh` explains at length.
108+install_archive() {
109+ local name=$1 url=$2 sha=$3 strip=$4
110+ local dest="$TC/$name" stamp="$TC/$name.sha256"
111+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
112+ return 0
113+ fi
114+ echo "desktop-toolchain: fetching $name" >&2
115+ local dl="$TC/.download.$name"
116+ rm -rf "$dest" "$dest.tmp" "$dl"
117+ mkdir -p "$dest.tmp"
118+ curl -fsSL --retry 3 -o "$dl" "$url"
119+ echo "$sha $dl" | sha256sum -c - >/dev/null
120+ tar -xf "$dl" -C "$dest.tmp" --strip-components="$strip"
121+ rm -f "$dl"
122+ mv "$dest.tmp" "$dest"
123+ echo "$sha" > "$stamp"
124+}
125+
126+# The same, for something that is one file rather than an archive.
127+install_file() {
128+ local name=$1 url=$2 sha=$3 into=$4
129+ local dest="$TC/$name" stamp="$TC/$name.sha256"
130+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
131+ return 0
132+ fi
133+ echo "desktop-toolchain: fetching $name" >&2
134+ rm -rf "$dest" "$dest.tmp"
135+ mkdir -p "$dest.tmp/$(dirname "$into")"
136+ curl -fsSL --retry 3 -o "$dest.tmp/$into" "$url"
137+ echo "$sha $dest.tmp/$into" | sha256sum -c - >/dev/null
138+ mv "$dest.tmp" "$dest"
139+ echo "$sha" > "$stamp"
140+}
141+
142+# Source, by revision. `git -c advice.detachedHead=false` because this is
143+# always a detached checkout and the advice is four lines of it per fetch.
144+install_source() {
145+ local name=$1 repo=$2 rev=$3
146+ local dest="$TC/$name" stamp="$TC/$name.rev"
147+ if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$rev" ]; then
148+ return 0
149+ fi
150+ echo "desktop-toolchain: cloning $name at ${rev:0:8}" >&2
151+ rm -rf "$dest" "$dest.tmp"
152+ # A rev is not a ref, so this is init-fetch rather than clone --branch:
153+ # `git clone --depth 1` cannot take a sha unless the server allows it, and
154+ # `fetch --depth 1 <sha>` is the form that works everywhere.
155+ mkdir -p "$dest.tmp"
156+ git -C "$dest.tmp" init -q
157+ git -C "$dest.tmp" remote add origin "$repo"
158+ git -C "$dest.tmp" fetch -q --depth 1 origin "$rev"
159+ git -C "$dest.tmp" -c advice.detachedHead=false checkout -q FETCH_HEAD
160+ mv "$dest.tmp" "$dest"
161+ echo "$rev" > "$stamp"
162+}
163+
164+check_pins() {
165+ if [ "$JOLT_NATIVE_SHA" = "0000000000000000000000000000000000000000000000000000000000000000" ]; then
166+ cat >&2 <<MSG
167+desktop-toolchain: the jolt-native portable tarball is not pinned yet.
168+
169+ It is published by the first pipeline to run on jolt-native's main at
170+ ${JOLT_NATIVE_REV:0:8}. Once it exists:
171+
172+ curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
173+
174+ and put that in JOLT_NATIVE_SHA here, or pass it for one run as
175+ FRQ_JOLT_NATIVE_SHA=<sha256>.
176+MSG
177+ exit 1
178+ fi
179+}
180+
181+install_all() {
182+ require_host_tools
183+ check_pins
184+ mkdir -p "$TC"
185+ # strip 1: the jolt tarball is rooted at a versioned directory.
186+ install_archive jolt "$JOLT_URL" "$JOLT_SHA" 1
187+ # strip 0: the portable tarball is rooted at lib/ and include/ already,
188+ # which is the shape a consumer is meant to take it in.
189+ install_archive jolt-native "$JOLT_NATIVE_URL" "$JOLT_NATIVE_SHA" 0
190+ install_file moq-ffi "$MOQ_FFI_URL" "$MOQ_FFI_SHA" lib/libmoq_ffi.so
191+ install_source glimmer "$GLIMMER_REPO" "$GLIMMER_REV"
192+ install_source jolt-native-src "$JOLT_NATIVE_REPO" "$JOLT_NATIVE_REV"
193+}
194+
195+# The environment, as shell.
196+#
197+# JOLT_NATIVE_LIB is how jolt resolves every `:jolt/native` name, and it wants
198+# ONE directory — the objects come from two places (the portable tarball and
199+# the moq-ffi release), so `tools/build-desktop.sh` stages them into one and
200+# this names where that landed.
201+print_env() {
202+ cat <<ENV
203+export FRQ_DESKTOP_TOOLCHAIN="$TC"
204+export JOLT_NATIVE_LIB="$TC/native"
205+export LD_LIBRARY_PATH="$TC/native\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}"
206+export FRQ_GLIMMER="$TC/glimmer"
207+export FRQ_GLIMMER_COSMIC="$TC/jolt-native-src/glimmer-backends/glimmer-cosmic"
208+export PATH="$TC/jolt:\$PATH"
209+ENV
210+}
211+
212+case "${1:-install}" in
213+ install) install_all ;;
214+ env) install_all; print_env ;;
215+ exec) install_all; eval "$(print_env)"; shift; [ "${1:-}" = "--" ] && shift; exec "$@" ;;
216+ *) echo "usage: desktop-toolchain.sh [install|env|exec -- cmd...]" >&2; exit 1 ;;
217+esac