nandi/jolt-nativepublic Fork 0
4f920afa410da72bfdb7a07d7faa0264c7bb8a08
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

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

glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d · on 4f920afa410da72bfdb7a07d7faa0264c7bb8a08 · nandi · 8d ago
README.md · 179 lines · 8.6 KBmarkdown
Blame HistoryOpen raw

jolt-native

Native capabilities for jolt, one shared
object per capability.

A jolt library binds a .so through jolt.ffi/defcfn: it declares typed
foreign functions and gets back integers, doubles and borrowed UTF-8 strings.
That is the whole vocabulary. These crates are the other side of that
boundary — the things worth writing in Rust because writing them again in
anything else would mean writing them badly.

crates/jolt-abi                 the rules every library here keeps at its edge
crates/jolt-vidya               the retained-tree C ABI  → libvidya.so
crates/vidya-core               the egui semantic layer behind it (theme, widgets, fonts)
crates/jolt-tui                 the same tree ABI, painted into a terminal → libjolttui.so
crates/jolt-moq                 freeq's AV media plane   → libjoltmoq.so
glimmer-backends/glimmer-vidya  the jolt side of libvidya: glimmer's backend
crates/jolt-cosmic              the same tree ABI, painted by libcosmic → libjoltcosmic.so
glimmer-backends/glimmer-cosmic glimmer's backend over libjoltcosmic (a spike)
jvui                            no .so at all: dvui's shape, in jolt, on SDL3
glimmer-backends/glimmer-jvui   glimmer's backend over jvui
wayland-bar-compositor          no .so either: a wlroots compositor in jolt, for panels on X11

All three objects land in one target/release, so a consumer points
LD_LIBRARY_PATH at one directory and names in :jolt/native only the ones it
actually wants.

Why a workspace and not one library with features

Because cargo features are additive within a library. A single
libjoltnative.so whose graphics and moq features selected the parts would
link egui and QUIC, Opus, H.264, cpal and V4L2 into one object, so a chat
client that is not in a call would carry the whole media plane in order to open
a window. The feature unification that comes with it has bitten this code
before: enabling eframe's wayland/x11 features for a desktop build pulled
them into the Android one and made the activity exit on launch.

A workspace keeps the parts of a monorepo worth having — one CI, one release,
shared conventions in jolt-abi — while each consumer links only what it asked
for. jolt's :jolt/native is a vector; naming two objects there is no harder
than naming one.

Building

The flake is the toolchain. nix develop is where the compiler, the C
toolchain, PipeWire's headers and the kernel headers bindgen wants come from,
so nothing needs to be installed beyond nix itself — no cc on PATH, no
rustup, no NDK.

nix develop                 # then:
just build                  # release .so files in target/release
just test                   # the whole workspace
just libdir                 # where to point LD_LIBRARY_PATH

just build is the edit loop; the nix outputs are the artifact, built against
a pinned compiler and a pinned crate graph:

nix build                   # all three objects  → result/lib
nix build .#android         # both device objects → result/lib/arm64-v8a
nix flake check             # fmt, clippy, tests, all three objects

Two things are worth knowing about:

  • bindgen runs libclang directly, so v4l2r needs to be pointed at
    linux/videodev2.h. The flake sets V4L2R_VIDEODEV2_H_PATH and
    BINDGEN_EXTRA_CLANG_ARGS from nixpkgs' linuxHeaders in both the devShell
    and the build, which is why building inside nix develop rather than a bare
    shell is the supported path.
  • PipeWire is a real system dependency — libspa-sys wants pkg-config
    and PipeWire's headers, and the flake supplies both. It is behind the
    default-on pipewire feature of jolt-moq; just test-minimal builds
    without it, for a machine outside the flake. Turning it off costs device
    names: only ALSA PCMs remain visible ("pipewire", "sysdefault") rather than
    the real sources a browser would offer, which makes picking a microphone
    guesswork.

Android

just ffi-android            # nix build .#android

Same sources and same Cargo.lock as the desktop objects; only the target
configuration moves. The NDK comes from nixpkgs, so this needs nothing
installed either. The camera wants two things from the APK that the build
cannot supply: a CameraCapture class in its classes.dex, and a call to
joltmoq_android_init — see android/jolt_main.c.

jolt-moq

freeq's audio and video calls, extracted from
sleek, which reached them by calling
Rust from Rust.

A freeq call has two halves, and only one of them is here.

Signaling stayed behind. A call is opened, joined and left over IRC
TAGMSGs — +freeq.at/av-start and its siblings — and the server broadcasts
+freeq.at/av-state back. Any client that speaks IRC already has everything it
needs for that, in whatever language it speaks IRC in; crossing an FFI boundary
to send a TAGMSG would be worse than not crossing one. This library begins once
the session id and the SFU token are known.

The media plane came across, because it is not a thing to write twice: MoQ
over QUIC, Opus, H.264, camera capture and the SFU's own dial rules come to
some three thousand lines that would say the same thing again in another
language and be wrong in different places.

crates/jolt-moq/include/joltmoq.h is the surface, and says more about each
call than this does. The shape of it:

  • Nothing calls back. Status is drained with joltmoq_poll_status, video
    with joltmoq_frame_poll, both from whatever thread the caller paints on. A
    callback into a foreign runtime from a tokio worker is a rule about threads
    that the caller has to keep and that nothing can check.
  • Frames are borrowed, not copied. joltmoq_frame_rgba points into the
    decoder's own buffer until the next poll. A frame is a megabyte or two;
    copying it out so the caller can hand it straight to a texture upload would
    be two copies a frame for nothing.
  • One call at a time. There is one microphone, so there is one session.

Its relationship to sleek

av.rs, av_media.rs, v4l2cam.rs, android_camera.rs and nv12_orient.rs
came from sleek's android/src. They are kept close to their originals so changes can still be moved between the
two by eye. What was dropped on the way was the signaling-and-presentation
half — ChannelCall, LocalCall, apply_av_state, av_state_message
which is sleek's own app state and belongs to a client, not to a media plane.
Dropping it also dropped the freeq-sdk dependency entirely.

Sleek still has its own copy. Pointing it at this crate instead is the obvious
next step and has not been taken yet; until it is, a fix made in one place
needs making in the other.

android_camera.rs is the one that did not come across unchanged. Sleek is a
single shared object, so its Camera2 bridge reads the JavaVM and the Activity
straight out of the AndroidApp that android-activity handed it. Here the glue
is in libvidya.so and the media plane is not, so the handles arrive through
joltmoq_android_init instead — see android_jni.rs for why the usual bridge,
ndk_context, cannot cross two cdylibs. Everything below that line is sleek's.

vidya

The theme layer, the widget set and the retained-tree ABI that glimmer paints
through. Imported from its own repo rather than depended on: egui is the only
backend anyone wants now, so the indirection that let libvidya be swapped for
a raylib or cimgui build of the same symbols was paying for a choice nobody was
going to make.

crates/vidya-core is the egui layer — and the 4MB under its assets/ is not
incidental: the Twemoji pack and the symbols font are include_bytes!d into
the library, because a shared object has no host app to install a font set for
it.

glimmer-backends/glimmer-vidya is the other side of that ABI, and is jolt
rather than Rust. A consumer takes it as a :local/root dep and libvidya.so on the
loader path — frq's deps.edn is the worked example.

Adding a crate

One crate, one .so, one capability. Depend on jolt-abi and keep its three
rules — catch panics at every entry point, lend strings from a Scratch rather
than handing out memory to free, and let the caller ask rather than calling it
back. A consumer then names your object in :jolt/native alongside whichever
others it wants, and pays for nothing else.

License

MIT — the same as the projects this code came from: crates/vidya-* and
glimmer-backends/glimmer-vidya from vidya,
crates/jolt-moq's media plane from
sleek. third-party/cpal is a vendored
pin of cpal and keeps its own licence.

  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
# jolt-native

Native capabilities for [jolt](https://github.com/jolt-lang/jolt), one shared
object per capability.

A jolt library binds a `.so` through `jolt.ffi/defcfn`: it declares typed
foreign functions and gets back integers, doubles and borrowed UTF-8 strings.
That is the whole vocabulary. These crates are the other side of that
boundary — the things worth writing in Rust because writing them again in
anything else would mean writing them badly.

```
crates/jolt-abi                 the rules every library here keeps at its edge
crates/jolt-vidya               the retained-tree C ABI  → libvidya.so
crates/vidya-core               the egui semantic layer behind it (theme, widgets, fonts)
crates/jolt-tui                 the same tree ABI, painted into a terminal → libjolttui.so
crates/jolt-moq                 freeq's AV media plane   → libjoltmoq.so
glimmer-backends/glimmer-vidya  the jolt side of libvidya: glimmer's backend
crates/jolt-cosmic              the same tree ABI, painted by libcosmic → libjoltcosmic.so
glimmer-backends/glimmer-cosmic glimmer's backend over libjoltcosmic (a spike)
jvui                            no .so at all: dvui's shape, in jolt, on SDL3
glimmer-backends/glimmer-jvui   glimmer's backend over jvui
wayland-bar-compositor          no .so either: a wlroots compositor in jolt, for panels on X11
```

All three objects land in one `target/release`, so a consumer points
`LD_LIBRARY_PATH` at one directory and names in `:jolt/native` only the ones it
actually wants.

## Why a workspace and not one library with features

Because cargo features are additive *within* a library. A single
`libjoltnative.so` whose `graphics` and `moq` features selected the parts would
link egui **and** QUIC, Opus, H.264, cpal and V4L2 into one object, so a chat
client that is not in a call would carry the whole media plane in order to open
a window. The feature unification that comes with it has bitten this code
before: enabling eframe's `wayland`/`x11` features for a desktop build pulled
them into the Android one and made the activity exit on launch.

A workspace keeps the parts of a monorepo worth having — one CI, one release,
shared conventions in `jolt-abi` — while each consumer links only what it asked
for. jolt's `:jolt/native` is a vector; naming two objects there is no harder
than naming one.

## Building

The flake is the toolchain. `nix develop` is where the compiler, the C
toolchain, PipeWire's headers and the kernel headers bindgen wants come from,
so **nothing needs to be installed** beyond nix itself — no `cc` on PATH, no
rustup, no NDK.

```bash
nix develop                 # then:
just build                  # release .so files in target/release
just test                   # the whole workspace
just libdir                 # where to point LD_LIBRARY_PATH
```

`just build` is the edit loop; the nix outputs are the artifact, built against
a pinned compiler and a pinned crate graph:

```bash
nix build                   # all three objects  → result/lib
nix build .#android         # both device objects → result/lib/arm64-v8a
nix flake check             # fmt, clippy, tests, all three objects
```

Two things are worth knowing about:

* **bindgen runs libclang directly**, so `v4l2r` needs to be pointed at
  `linux/videodev2.h`. The flake sets `V4L2R_VIDEODEV2_H_PATH` and
  `BINDGEN_EXTRA_CLANG_ARGS` from nixpkgs' `linuxHeaders` in both the devShell
  and the build, which is why building inside `nix develop` rather than a bare
  shell is the supported path.
* **PipeWire** is a real system dependency — `libspa-sys` wants `pkg-config`
  and PipeWire's headers, and the flake supplies both. It is behind the
  default-on `pipewire` feature of `jolt-moq`; `just test-minimal` builds
  without it, for a machine outside the flake. Turning it off costs device
  *names*: only ALSA PCMs remain visible ("pipewire", "sysdefault") rather than
  the real sources a browser would offer, which makes picking a microphone
  guesswork.

### Android

```bash
just ffi-android            # nix build .#android
```

Same sources and same `Cargo.lock` as the desktop objects; only the target
configuration moves. The NDK comes from nixpkgs, so this needs nothing
installed either. The camera wants two things from the APK that the build
cannot supply: a `CameraCapture` class in its `classes.dex`, and a call to
`joltmoq_android_init` — see `android/jolt_main.c`.

## jolt-moq

freeq's audio and video calls, extracted from
[sleek](https://github.com/codegod100/sleek), which reached them by calling
Rust from Rust.

A freeq call has two halves, and only one of them is here.

**Signaling stayed behind.** A call is opened, joined and left over IRC
TAGMSGs — `+freeq.at/av-start` and its siblings — and the server broadcasts
`+freeq.at/av-state` back. Any client that speaks IRC already has everything it
needs for that, in whatever language it speaks IRC in; crossing an FFI boundary
to send a TAGMSG would be worse than not crossing one. This library begins once
the session id and the SFU token are known.

**The media plane came across**, because it is not a thing to write twice: MoQ
over QUIC, Opus, H.264, camera capture and the SFU's own dial rules come to
some three thousand lines that would say the same thing again in another
language and be wrong in different places.

`crates/jolt-moq/include/joltmoq.h` is the surface, and says more about each
call than this does. The shape of it:

* **Nothing calls back.** Status is drained with `joltmoq_poll_status`, video
  with `joltmoq_frame_poll`, both from whatever thread the caller paints on. A
  callback into a foreign runtime from a tokio worker is a rule about threads
  that the caller has to keep and that nothing can check.
* **Frames are borrowed, not copied.** `joltmoq_frame_rgba` points into the
  decoder's own buffer until the next poll. A frame is a megabyte or two;
  copying it out so the caller can hand it straight to a texture upload would
  be two copies a frame for nothing.
* **One call at a time.** There is one microphone, so there is one session.

### Its relationship to sleek

`av.rs`, `av_media.rs`, `v4l2cam.rs`, `android_camera.rs` and `nv12_orient.rs`
came from sleek's `android/src`. They are kept close to their originals so changes can still be moved between the
two by eye. What was dropped on the way was the signaling-and-presentation
half — `ChannelCall`, `LocalCall`, `apply_av_state`, `av_state_message`which is sleek's own app state and belongs to a client, not to a media plane.
Dropping it also dropped the `freeq-sdk` dependency entirely.

Sleek still has its own copy. Pointing it at this crate instead is the obvious
next step and has not been taken yet; until it is, a fix made in one place
needs making in the other.

`android_camera.rs` is the one that did not come across unchanged. Sleek is a
single shared object, so its Camera2 bridge reads the `JavaVM` and the Activity
straight out of the `AndroidApp` that android-activity handed it. Here the glue
is in `libvidya.so` and the media plane is not, so the handles arrive through
`joltmoq_android_init` instead — see `android_jni.rs` for why the usual bridge,
`ndk_context`, cannot cross two cdylibs. Everything below that line is sleek's.

## vidya

The theme layer, the widget set and the retained-tree ABI that glimmer paints
through. Imported from its own repo rather than depended on: egui is the only
backend anyone wants now, so the indirection that let `libvidya` be swapped for
a raylib or cimgui build of the same symbols was paying for a choice nobody was
going to make.

`crates/vidya-core` is the egui layer — and the 4MB under its `assets/` is not
incidental: the Twemoji pack and the symbols font are `include_bytes!`d into
the library, because a shared object has no host app to install a font set for
it.

`glimmer-backends/glimmer-vidya` is the other side of that ABI, and is jolt
rather than Rust. A consumer takes it as a `:local/root` dep and `libvidya.so` on the
loader path — frq's `deps.edn` is the worked example.

## Adding a crate

One crate, one `.so`, one capability. Depend on `jolt-abi` and keep its three
rules — catch panics at every entry point, lend strings from a `Scratch` rather
than handing out memory to free, and let the caller ask rather than calling it
back. A consumer then names your object in `:jolt/native` alongside whichever
others it wants, and pays for nothing else.

## License

MIT — the same as the projects this code came from: `crates/vidya-*` and
`glimmer-backends/glimmer-vidya` from [vidya](https://tangled.org/nandi.uk/vidya),
`crates/jolt-moq`'s media plane from
[sleek](https://github.com/codegod100/sleek). `third-party/cpal` is a vendored
pin of [cpal](https://github.com/RustAudio/cpal) and keeps its own licence.